diff --git a/assets/webui/index.html b/assets/webui/index.html index 92aa235..0d8a7fa 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -353,8 +353,59 @@ + + + 证书 + 私钥 + 私钥口令 + 信任根 + 中间证书 + 电子印章 + + 位置页面骑缝 + 页码 + 横坐标mm + 纵坐标mm + 宽度mm + 高度mm + + 方式追加替换 + 禁止追加 + 取消签署 + + + + + 信任根 + 证书 + 时间戳根 + 时间戳 + 撤销表 + OCSP响应 + 要求时间戳撤销检查 + 取消验签 + + + + + 方式口令证书 + 用户 + 口令 + 证书 + 私钥 + 私钥口令 + 取消解锁 + + + + 方式口令证书 + 用户 + 口令 + 确认 + 证书 + 范围 @@ -500,6 +551,10 @@ 0 + + 安全加密签章验签 + 加密未加密算法 + 附件 diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index c70ab0d..bacb25c 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -645,6 +645,23 @@ button:not(:disabled):hover { padding: 12px; } +.security-dialog { + width: 360px; +} + +.security-dialog .form-row { + grid-template-columns: 4em minmax(0, 1fr); +} + +#verifyForm .form-row { + grid-template-columns: 5em minmax(0, 1fr); +} + +.security-dialog .form-row > label, +.security-dialog .form-row > span { + text-align-last: justify; +} + .form-row { display: grid; grid-template-columns: 2em minmax(0, 1fr); @@ -711,6 +728,7 @@ button:not(:disabled):hover { } .form-row input[type="text"], +.form-row input[type="password"], .form-row input[type="number"], .form-row input[type="file"], .form-row select, @@ -895,6 +913,8 @@ body[data-hide-meta] .meta-panel { border-right: 1px solid var(--line); background: var(--panel); overflow: auto; + font-size: 13px; + line-height: 1.5; } .page-list-panel { @@ -1168,7 +1188,7 @@ body[data-hide-meta] .meta-panel { gap: 6px; margin: 8px 0; color: var(--text-muted); - font-size: 11px; + font-size: 12px; font-variant-numeric: tabular-nums; } @@ -1205,7 +1225,7 @@ body[data-hide-meta] .meta-panel { display: block; margin-bottom: 3px; color: var(--text-muted); - font-size: 11px; + font-size: 12px; } .search-result mark { @@ -1284,7 +1304,13 @@ button.outline-link:hover { transition: border-color 0.16s ease, background-color 0.16s ease; } -.page-list-item[aria-current] { +.page-list-item:not(:disabled):hover { + border-color: var(--line-strong); + background: #f0f5f4; +} + +.page-list-item[aria-current], +.page-list-item[aria-current]:hover { border-color: var(--accent); color: var(--accent-strong); font-weight: 700; @@ -1297,7 +1323,8 @@ button.outline-link:hover { } .page-list-item[aria-current]:not([aria-pressed="false"]), -.page-list-item[aria-pressed="true"] { +.page-list-item[aria-pressed="true"], +.page-list-item[aria-pressed="true"]:hover { border-color: var(--accent); background: #e4f3ef; color: var(--accent-strong); @@ -1351,7 +1378,7 @@ button.outline-link:hover { } .thumb-size { - font-size: 11px; + font-size: 12px; color: var(--text-muted); font-weight: 400; } @@ -2037,7 +2064,8 @@ button.outline-link:hover { .font-panel, .doc-font-panel, .attachment-panel, -.signature-panel { +.signature-panel, +.security-panel { margin-top: 18px; padding-top: 12px; border-top: 1px solid var(--line); @@ -2070,7 +2098,7 @@ button.outline-link:hover { .attachment-detail { color: var(--text-muted); - font-size: 11px; + font-size: 12px; } .count-pill { @@ -2083,7 +2111,7 @@ button.outline-link:hover { border: 1px solid var(--line); background: #ffffff; color: var(--text-muted); - font-size: 11px; + font-size: 12px; text-align: center; white-space: nowrap; } @@ -2104,7 +2132,6 @@ button.outline-link:hover { transition: border-color 0.16s ease, background-color 0.16s ease; } -.page-list-item:not(:disabled):hover, .doc-font-row:hover, .signature-row:hover, .font-row:hover { @@ -2194,10 +2221,6 @@ button.outline-link:hover { flex-wrap: wrap; justify-content: flex-end; gap: 4px; -} - -.doc-font-badges, -.signature-badges { float: right; margin-left: 8px; } @@ -2210,7 +2233,7 @@ button.outline-link:hover { padding: 3px 6px; background: #eef1f4; color: var(--text-muted); - font-size: 11px; + font-size: 12px; line-height: 1.35; white-space: nowrap; } @@ -2235,19 +2258,40 @@ button.outline-link:hover { .info-line { display: grid; - grid-template-columns: 30px minmax(0, 1fr); + grid-template-columns: 2em minmax(0, 1fr); align-items: start; gap: 7px; color: var(--text-muted); - font-size: 11px; - line-height: 1.35; + font-size: 12px; + line-height: 1.5; } .info-label { - color: #7b8791; + color: var(--text-muted); white-space: nowrap; } +.meta-panel .info-line { + grid-template-columns: 4em minmax(0, 1fr); +} + +.meta-panel .info-label { + text-align-last: justify; +} + +.signature-row details { + font-size: 12px; +} + +.signature-row summary { + cursor: pointer; + color: var(--accent-strong); +} + +.signature-row details[open] > .info-line { + margin-top: 6px; +} + .info-value { min-width: 0; overflow-wrap: anywhere; @@ -2263,8 +2307,8 @@ button.outline-link:hover { .doc-font-detail { color: var(--text-muted); - font-size: 11px; - line-height: 1.35; + font-size: 12px; + line-height: 1.5; overflow-wrap: anywhere; } @@ -2337,7 +2381,7 @@ button.outline-link:hover { border: 1px solid var(--line); background: #f8fafb; color: var(--text-muted); - font-size: 11px; + font-size: 12px; text-align: center; white-space: nowrap; } @@ -2348,7 +2392,7 @@ button.outline-link:hover { gap: 3px; height: 26px; color: var(--text-muted); - font-size: 11px; + font-size: 12px; white-space: nowrap; } diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 670d931..b06b433 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -24,6 +24,7 @@ let wasmWorker = null; let wasmRequestID = 0; let wasmRecoveryTimer = 0; let textMeasure = null; +let credentialRequest = null; const state = { composite: null, @@ -35,6 +36,8 @@ const state = { wasmRecovering: false, wasmRecoveries: 0, exporting: false, + signing: false, + signCanceled: false, exportRequestID: 0, editing: false, selectObjects: true, @@ -44,6 +47,7 @@ const state = { savedRevision: null, insertObject: null, importPageCount: 0, + importEncrypted: false, importing: false, pageSelection: new Set(), pageSelectionAnchor: null, @@ -102,11 +106,74 @@ const state = { exportFormats: [], exportPages: null, exportBackdrop: false, + exportEncrypted: false, showPages: !COMPACT_LAYOUT.matches, showMeta: !COMPACT_LAYOUT.matches, }; const el = { + signButton: document.querySelector("#signButton"), + signPanel: document.querySelector("#signPanel"), + signForm: document.querySelector("#signForm"), + signCertificate: document.querySelector("#signCertificate"), + signKey: document.querySelector("#signKey"), + signKeyPassword: document.querySelector("#signKeyPassword"), + signRoots: document.querySelector("#signRoots"), + signIntermediates: document.querySelector("#signIntermediates"), + signSeal: document.querySelector("#signSeal"), + signPlacementFields: document.querySelector("#signPlacementFields"), + signPlacement: document.querySelector("#signPlacement"), + signPages: document.querySelector("#signPages"), + signX: document.querySelector("#signX"), + signY: document.querySelector("#signY"), + signWidth: document.querySelector("#signWidth"), + signHeight: document.querySelector("#signHeight"), + signMode: document.querySelector("#signMode"), + signLock: document.querySelector("#signLock"), + signStatus: document.querySelector("#signStatus"), + signCancel: document.querySelector("#signCancel"), + verifyButton: document.querySelector("#verifyButton"), + verifyPanel: document.querySelector("#verifyPanel"), + verifyForm: document.querySelector("#verifyForm"), + verifyRoots: document.querySelector("#verifyRoots"), + verifyCerts: document.querySelector("#verifyCerts"), + verifyTimeRoots: document.querySelector("#verifyTimeRoots"), + verifyTokens: document.querySelector("#verifyTokens"), + verifyCRLs: document.querySelector("#verifyCRLs"), + verifyOCSP: document.querySelector("#verifyOCSP"), + verifyRequireTime: document.querySelector("#verifyRequireTime"), + verifyRequireRevocation: document.querySelector("#verifyRequireRevocation"), + verifyStatus: document.querySelector("#verifyStatus"), + verifyCancel: document.querySelector("#verifyCancel"), + securityPanel: document.querySelector("#securityPanel"), + securityEncryption: document.querySelector("#securityEncryption"), + securityMethod: document.querySelector("#securityMethod"), + securityMethodRow: document.querySelector("#securityMethodRow"), + encryptSaveButton: document.querySelector("#encryptSaveButton"), + credentialsPanel: document.querySelector("#credentialsPanel"), + credentialsType: document.querySelector("#credentialsType"), + credentialsPasswordRow: document.querySelector("#credentialsPasswordRow"), + credentialsCertificateRow: document.querySelector("#credentialsCertificateRow"), + credentialsKeyRow: document.querySelector("#credentialsKeyRow"), + credentialsCertificate: document.querySelector("#credentialsCertificate"), + credentialsKey: document.querySelector("#credentialsKey"), + credentialsKeyPassword: document.querySelector("#credentialsKeyPassword"), + credentialsKeyPasswordRow: document.querySelector("#credentialsKeyPasswordRow"), + credentialsForm: document.querySelector("#credentialsForm"), + credentialsUser: document.querySelector("#credentialsUser"), + credentialsPassword: document.querySelector("#credentialsPassword"), + credentialsStatus: document.querySelector("#credentialsStatus"), + credentialsCancel: document.querySelector("#credentialsCancel"), + encryptionFields: document.querySelector("#encryptionFields"), + encryptionType: document.querySelector("#encryptionType"), + encryptionUserRow: document.querySelector("#encryptionUserRow"), + encryptionPasswordRow: document.querySelector("#encryptionPasswordRow"), + encryptionConfirmRow: document.querySelector("#encryptionConfirmRow"), + encryptionCertificatesRow: document.querySelector("#encryptionCertificatesRow"), + encryptionCertificates: document.querySelector("#encryptionCertificates"), + encryptionUser: document.querySelector("#encryptionUser"), + encryptionPassword: document.querySelector("#encryptionPassword"), + encryptionConfirm: document.querySelector("#encryptionConfirm"), ofdInput: document.querySelector("#ofdInput"), ofdButton: document.querySelector("#ofdButton"), newButton: document.querySelector("#newButton"), @@ -755,6 +822,7 @@ el.importPanel.addEventListener("cancel", event => { }); el.importPanel.addEventListener("close", () => { state.importPageCount = 0; + state.importEncrypted = false; el.importForm.reset(); if (state.ready) callWASM("ofdgoLoadImport", null).catch(err => showError(err, false)); }); @@ -770,7 +838,12 @@ el.importForm.addEventListener("submit", async event => { event.preventDefault(); if (document.body.hasAttribute("aria-busy") || !state.importPageCount || !el.importForm.reportValidity()) return; const position = { before:state.pageIndex, after:state.pageIndex+1, first:0, last:state.doc.pageCount }[el.importPosition.value]; - const saved = await changeDocument("ofdgoImportPages", null, el.importRange.value === "custom" ? el.importPages.value : "", position, el.importOutlines.checked); + const options = []; + if (state.importEncrypted) { + if (!window.confirm(state.doc.encryption?.encrypted ? "按目标文档的加密策略导入,是否继续?" : "目标未加密,导入内容将按明文保存,是否继续?")) return; + options.push(true); + } + const saved = await changeDocument("ofdgoImportPages", null, el.importRange.value === "custom" ? el.importPages.value : "", position, el.importOutlines.checked, ...options); if (saved) el.importPanel.close(); }); editorClick(el.copyPageButton, () => changeSelectedPages("copy")); @@ -926,6 +999,60 @@ el.insertPanel.addEventListener("close", () => { state.insertObject = null; }); el.insertForm.addEventListener("submit", insertObject); el.textFontAdd.addEventListener("click", () => openFontFile(el.fontInput)); el.saveButton.addEventListener("click", () => exportFile(true, null, "ofd")); +el.encryptSaveButton.addEventListener("click", () => openExportPanel(true)); +el.signButton.addEventListener("click", () => { + if (!state.doc || document.body.hasAttribute("aria-busy")) return; + el.signForm.reset(); + el.signPages.value = state.editing && state.pageSelection.size ? selectedPageRange() : String(state.pageIndex + 1); + el.signStatus.textContent = ""; + updateSignPlacement(); + el.signPanel.showModal(); +}); +el.signSeal.addEventListener("change", updateSignPlacement); +el.signPlacement.addEventListener("change", updateSignPlacement); +el.signCancel.addEventListener("click", () => state.signing ? cancelSigning() : el.signPanel.close()); +el.signPanel.addEventListener("cancel", event => { + if (state.signing) { event.preventDefault(); cancelSigning(); } +}); +el.signPanel.addEventListener("close", () => { if (!el.signPanel.open) el.signForm.reset(); }); +el.signForm.addEventListener("submit", signDocument); +el.verifyButton.addEventListener("click", () => { + if (!state.doc || document.body.hasAttribute("aria-busy")) return; + el.verifyForm.reset(); + el.verifyStatus.textContent = ""; + el.verifyPanel.showModal(); +}); +el.verifyCancel.addEventListener("click", () => el.verifyPanel.close()); +el.verifyPanel.addEventListener("close", () => { if (!el.verifyPanel.open) el.verifyForm.reset(); }); +el.verifyForm.addEventListener("submit", verifyDocument); +el.credentialsCancel.addEventListener("click", () => finishCredentials(null)); +el.credentialsPanel.addEventListener("cancel", event => { event.preventDefault(); finishCredentials(null); }); +el.credentialsPanel.addEventListener("close", () => { if (!el.credentialsPanel.open) finishCredentials(null); }); +el.credentialsType.addEventListener("change", updateCredentialsType); +el.credentialsForm.addEventListener("submit", async event => { + event.preventDefault(); + if (!credentialRequest) return; + if (el.credentialsType.value === "certificate") { + const request = credentialRequest; + let key; + const keyPassword = new TextEncoder().encode(el.credentialsKeyPassword.value); + el.credentialsKeyPassword.value = ""; + try { + key = (await securityFiles(el.credentialsKey))[0]; + const certificate = (await securityFiles(el.credentialsCertificate))[0]; + if (credentialRequest !== request) { key?.fill(0); keyPassword.fill(0); return; } + if (!key || !certificate) { el.credentialsStatus.textContent = "请选择证书和私钥"; key?.fill(0); keyPassword.fill(0); return; } + finishCredentials({password:new Uint8Array(), key, keyPassword, certificate, userName:el.credentialsUser.value.trim()}); + } catch { + key?.fill(0); + keyPassword.fill(0); + if (credentialRequest === request) el.credentialsStatus.textContent = "文件读取失败"; + } + return; + } + if (!el.credentialsPassword.value) return; + finishCredentials({ password: new TextEncoder().encode(el.credentialsPassword.value), userName: el.credentialsUser.value.trim() }); +}); el.togglePagesButton.addEventListener("click", () => toggleSidebar("pages")); el.toggleMetaButton.addEventListener("click", () => toggleSidebar("meta")); el.pagesTab.addEventListener("click", () => showNavigation(el.pagesTab)); @@ -983,8 +1110,10 @@ el.viewerPanel.addEventListener("paste", pasteEditorContent); document.addEventListener("selectionchange", syncSelection); el.exportFormat.addEventListener("change", () => updateDPIControl()); el.exportPageButton.addEventListener("click", () => exportFile(false)); -el.exportButton.addEventListener("click", openExportPanel); +el.exportButton.addEventListener("click", () => openExportPanel()); el.exportCancel.addEventListener("click", () => el.exportPanel.close()); +el.exportPanel.addEventListener("close", () => { if (!el.exportPanel.open) clearEncryptionForm(); }); +el.encryptionType.addEventListener("change", updateEncryptionType); el.cancelExportButton.addEventListener("click", cancelExport); el.exportPanel.addEventListener("pointerdown", (event) => { state.exportBackdrop = event.target === el.exportPanel; @@ -1000,9 +1129,32 @@ el.exportSpecified.addEventListener("change", () => { el.exportRange.focus(); }); el.exportRange.addEventListener("input", updateExportRange); -el.exportForm.addEventListener("submit", (event) => { +el.exportForm.addEventListener("submit", async (event) => { event.preventDefault(); if (!el.exportSubmit.disabled) { + if (state.exportEncrypted) { + const certificates = el.encryptionType.value === "certificate"; + if (certificates && !el.encryptionCertificates.files?.length) { + el.exportRangeStatus.textContent = "请选择接收者证书"; + return; + } + if (!certificates && !el.encryptionPassword.value) { + el.exportRangeStatus.textContent = "请输入口令"; + el.encryptionPassword.focus(); + return; + } + if (!certificates && el.encryptionPassword.value !== el.encryptionConfirm.value) { + el.exportRangeStatus.textContent = "口令不一致"; + el.encryptionConfirm.focus(); + return; + } + const encryption = certificates + ? {password:new Uint8Array(), userName:"", recipientFiles:Array.from(el.encryptionCertificates.files)} + : { password: new TextEncoder().encode(el.encryptionPassword.value), userName: el.encryptionUser.value.trim() }; + clearEncryptionForm(); + try { return await exportFile(true, state.exportPages, "ofd", encryption); } + finally { encryption.password.fill(0); } + } return exportFile(true, state.exportPages); } }); @@ -1317,18 +1469,20 @@ async function loadImportFile() { const file = el.importFile.files[0]; const openSeq = state.openSeq; state.importPageCount = 0; + state.importEncrypted = false; el.importSubmit.disabled = true; el.importStatus.textContent = ""; setBusy(true); try { const data = file ? new Uint8Array(await file.arrayBuffer()) : null; if (openSeq !== state.openSeq) return; - const info = await callWASM("ofdgoLoadImport", data); + const info = data ? await openWithCredentials(null, openSeq, data, true) : await callWASM("ofdgoLoadImport", null); if (openSeq !== state.openSeq || !el.importPanel.open) { await callWASM("ofdgoLoadImport", null); return; } state.importPageCount = info?.pageCount || 0; + state.importEncrypted = Boolean(info?.encrypted); el.importPages.title = state.importPageCount ? `页码 1-${state.importPageCount}` : "页码"; el.importStatus.textContent = info?.signed ? "保留签章外观,原签名不再有效" : ""; el.importSubmit.disabled = !state.importPageCount; @@ -1494,6 +1648,7 @@ async function createDocument(event) { return; } const title = el.createName.value.trim() || "未命名"; + clearSecurityForms(); const openSeq = ++state.openSeq; setBusy(true, "正在新建文档", null, "正在新建文档"); try { @@ -2714,10 +2869,11 @@ async function loadWASM() { if (wasmRequests.get(data.id)?.openSeq === state.openSeq && state.exporting) { if (data.stage === "save") { el.cancelExportButton.disabled = true; + if (state.signing) el.signCancel.disabled = true; setProgress("正在保存", null); } else if (!el.cancelExportButton.disabled) { if (data.stage === "prepare") { - const label = { snapshot: "正在准备", ids: "正在检查", commit: "正在整理", fonts: "正在处理字体", pages: "正在处理页面", references: "正在检查引用", resources: "正在整理资源", write: "正在写入" }[data.phase]; + const label = { snapshot: "正在准备", ids: "正在检查", commit: "正在整理", fonts: "正在处理字体", pages: "正在处理页面", references: "正在检查引用", resources: "正在整理资源", write: "正在写入", sign: "正在签署", encrypt: "正在加密" }[data.phase]; setProgress(label, data.total ? data.completed / data.total * 100 : null); } else if (data.completed === data.total) { setProgress("正在收尾", null); @@ -2753,6 +2909,7 @@ async function loadWASM() { request.resolve(data.data); } else { const err = new Error(missingGlyphMessage(data.missingGlyphs) || (data.reasonCode === "layoutRequired" ? "跨段或换行修改需先设置段落排版" : data.error)); + err.code = data.code; if (data.canceled) { err.name = "AbortError"; } @@ -2768,6 +2925,7 @@ function markWASMExited(wasmSeq = state.wasmSeq, err) { return; } state.ready = false; + clearSecurityForms(); state.wasmExited = true; wasmWorker?.terminate(); wasmWorker = null; @@ -2830,6 +2988,7 @@ async function openOFD(file) { if (!discardChanges()) { return; } + clearSecurityForms(); state.wasmRecoveries = 0; const openSeq = ++state.openSeq; setBusy(true, "正在读取文档", 10, STATUS.opening); @@ -3306,6 +3465,86 @@ function updateFontPermissionHint() { el.fontPermissionHint.hidden = !fontManager.canReadLocal() || fontManager.permission === "granted"; } +function clearSecurityForms() { + finishCredentials(null); + clearEncryptionForm(); + el.signKey.value = ""; + el.signKeyPassword.value = ""; + if (el.signPanel.open) el.signPanel.close(); + if (el.verifyPanel.open) el.verifyPanel.close(); +} + +function updateCredentialsType() { + const certificate = el.credentialsType.value === "certificate"; + el.credentialsStatus.textContent = ""; + el.credentialsPasswordRow.hidden = certificate; + el.credentialsCertificateRow.hidden = el.credentialsKeyRow.hidden = !certificate; + el.credentialsKeyPasswordRow.hidden = !certificate; + el.credentialsPassword.required = !certificate; + el.credentialsCertificate.required = el.credentialsKey.required = certificate; + el.credentialsPassword.value = ""; + el.credentialsCertificate.value = el.credentialsKey.value = ""; + el.credentialsKeyPassword.value = ""; +} + +function finishCredentials(credentials) { + const request = credentialRequest; + credentialRequest = null; + el.credentialsPassword.value = ""; + el.credentialsUser.value = ""; + el.credentialsCertificate.value = el.credentialsKey.value = ""; + el.credentialsKeyPassword.value = ""; + if (el.credentialsPanel.open) el.credentialsPanel.close(); + if (request && request.openSeq === state.openSeq) request.resolve(credentials); + else { + credentials?.password.fill(0); + credentials?.key?.fill(0); + credentials?.keyPassword?.fill(0); + request?.resolve(null); + } +} + +function requestCredentials(code, openSeq) { + finishCredentials(null); + el.credentialsStatus.textContent = code === "invalidCredentials" ? "解锁失败,请重试" : ""; + el.progressPanel.hidden = true; + return new Promise(resolve => { + credentialRequest = { resolve, openSeq }; + el.credentialsPanel.showModal(); + (el.credentialsType.value === "certificate" ? el.credentialsCertificate : el.credentialsPassword).focus(); + }); +} + +async function openWithCredentials(fonts, openSeq, data = state.ofdBytes, importing = false) { + let credentials = null; + try { + while (openSeq === state.openSeq) { + try { + return importing ? await callWASM("ofdgoLoadImport", data.slice(), credentials) + : await callWASM("ofdgoOpen", data, fonts, state.renderAnnotations, credentials); + } catch (err) { + if (openSeq !== state.openSeq || !["credentialsRequired", "invalidCredentials"].includes(err.code)) throw err; + credentials?.password.fill(0); + credentials?.key?.fill(0); + credentials?.keyPassword?.fill(0); + credentials = await requestCredentials(err.code, openSeq); + if (!credentials || openSeq !== state.openSeq) { + const canceled = new Error("打开已取消"); + canceled.name = "AbortError"; + throw canceled; + } + } + } + const canceled = new Error("打开已取消"); + canceled.name = "AbortError"; + throw canceled; + } finally { + credentials?.password.fill(0); + credentials?.key?.fill(0); + credentials?.keyPassword?.fill(0); + } +} + async function openDocument(options = {}) { if (!state.ofdBytes && !state.editorInfo) { return; @@ -3341,7 +3580,7 @@ async function openDocument(options = {}) { } const doc = options.doc || (options.reuseSession ? await callWASM("ofdgoConfigure", fonts, state.renderAnnotations) - : await callWASM("ofdgoOpen", state.ofdBytes, fonts, state.renderAnnotations)); + : await openWithCredentials(fonts, openSeq)); if (openSeq !== state.openSeq) { return; } @@ -3439,6 +3678,12 @@ async function openDocument(options = {}) { return; } if (options.throwError) throw err; + if (!options.reuseSession && !options.doc) { + state.ofdBytes = null; + state.doc = null; + renderSecurity(); + updateControls(); + } showError(err, true); } finally { if (openSeq === state.openSeq) { @@ -3522,6 +3767,7 @@ async function downloadAttachment(attachment) { if (!state.doc || document.body.hasAttribute("aria-busy")) { return; } + if (state.doc.encryption?.encrypted && !window.confirm("附件将按明文下载,是否继续?")) return; const openSeq = state.openSeq; state.exporting = true; updateControls(); @@ -3558,11 +3804,165 @@ async function downloadAttachment(attachment) { } } -function openExportPanel() { +function updateEncryptionType() { + const certificates = el.encryptionType.value === "certificate"; + el.encryptionUserRow.hidden = el.encryptionPasswordRow.hidden = el.encryptionConfirmRow.hidden = certificates; + el.encryptionCertificatesRow.hidden = !certificates; + el.encryptionPassword.required = el.encryptionConfirm.required = state.exportEncrypted && !certificates; + el.encryptionCertificates.required = state.exportEncrypted && certificates; + el.encryptionPassword.value = el.encryptionConfirm.value = ""; + el.encryptionCertificates.value = ""; +} + +async function encryptionRecipients(files) { + const names = new Set(); + return Promise.all(files.map(async file => { + const base = file.name.replace(/\.[^.]+$/, "") || "用户"; + let name = base, index = 2; + while (names.has(name)) name = `${base} ${index++}`; + names.add(name); + return {userName:name, certificate:new Uint8Array(await file.arrayBuffer())}; + })); +} + +function clearEncryptionForm() { + el.encryptionPassword.value = ""; + el.encryptionConfirm.value = ""; + el.encryptionUser.value = ""; + el.encryptionCertificates.value = ""; + state.exportEncrypted = false; +} + +function renderSecurity() { + const encryption = state.doc?.encryption; + el.securityPanel.hidden = !state.doc; + el.securityEncryption.textContent = encryption?.encrypted ? "已加密" : "未加密"; + el.securityMethod.textContent = encryption?.method || ""; + el.securityMethodRow.hidden = !encryption?.method; + el.encryptSaveButton.disabled = !state.doc || !state.ready || state.exporting || document.body.hasAttribute("aria-busy"); + el.verifyButton.disabled = el.encryptSaveButton.disabled; + el.signButton.disabled = el.encryptSaveButton.disabled; +} + +function cancelSigning() { + if (el.signCancel.disabled) return; + state.signCanceled = true; + el.signStatus.textContent = "正在取消"; + cancelExport(); +} + +function updateSignPlacement() { + const stamp = Boolean(el.signSeal.files?.length); + el.signPlacementFields.hidden = !stamp; + el.signPages.required = stamp; + el.signX.disabled = !stamp || el.signPlacement.value === "seam"; +} + +async function signDocument(event) { + event.preventDefault(); + if (!state.doc || document.body.hasAttribute("aria-busy")) return; + if (!el.signCertificate.files?.length || !el.signKey.files?.length || !el.signRoots.files?.length) { + el.signStatus.textContent = "请选择证书、私钥和信任根"; + return; + } + if (el.signMode.value === "replace" && !window.confirm("替换现有签名,是否继续?")) return; + let openSeq = state.openSeq; + let key; + const keyPassword = new TextEncoder().encode(el.signKeyPassword.value); + el.signKeyPassword.value = ""; + state.exporting = true; + state.signing = true; + state.signCanceled = false; + el.signCancel.disabled = false; + setBusy(true, "正在签署", null, "正在签署"); + el.signForm.querySelectorAll("input, select, button[type=submit]").forEach(input => { input.disabled = true; }); + try { + const name = `${baseFileName()}_签章.ofd`; + const file = window.showSaveFilePicker ? await window.showSaveFilePicker({suggestedName:name, types:[{description:"OFD",accept:{"application/ofd":[".ofd"]}}]}) : null; + if (openSeq !== state.openSeq || state.signCanceled) return; + if (canvasEditor.input || canvasEditor.crop || canvasEditor.nudge || canvasEditor.nudgeCommit) { + setBusy(false); + if (!await canvasEditor.commitNudge() || !await canvasEditor.commitText() || !await canvasEditor.commitCrop()) return; + openSeq = state.openSeq; + setBusy(true, "正在签署", null, "正在签署"); + } + key = (await securityFiles(el.signKey))[0]; + const options = { + key, keyPassword, certificate:(await securityFiles(el.signCertificate))[0], roots:await securityFiles(el.signRoots), + intermediates:await securityFiles(el.signIntermediates), seal:(await securityFiles(el.signSeal))[0] || null, + pages:el.signPages.value.trim(), placement:el.signPlacement.value, + x:Number(el.signX.value), y:Number(el.signY.value), width:Number(el.signWidth.value), height:Number(el.signHeight.value), + mode:el.signMode.value, lock:el.signLock.checked, + }; + el.signKey.value = ""; + if (openSeq !== state.openSeq || state.signCanceled || !el.signPanel.open) return; + const result = await callWASM("ofdgoSaveSigned", null, options, file); + if (openSeq !== state.openSeq) return; + if (result.blob) downloadBytes(result.blob, result.mime, name); + el.signPanel.close(); + setStatus("签署已另存"); + } catch (err) { + if (openSeq === state.openSeq) el.signStatus.textContent = err.name === "AbortError" ? "签署已取消" : err.message; + } finally { + key?.fill(0); + keyPassword.fill(0); + el.signKey.value = ""; + el.signForm.querySelectorAll("input, select, button[type=submit]").forEach(input => { input.disabled = false; }); + el.signCancel.disabled = false; + if (state.signCanceled && openSeq === state.openSeq) el.signStatus.textContent = "签署已取消"; + state.signing = false; + updateSignPlacement(); + state.exporting = false; + state.exportRequestID = 0; + el.cancelExportButton.hidden = true; + if (openSeq === state.openSeq) { setBusy(false); updateControls(); } + } +} + +async function securityFiles(input) { + return Promise.all(Array.from(input.files || [], async file => new Uint8Array(await file.arrayBuffer()))); +} + +async function verifyDocument(event) { + event.preventDefault(); + if (!state.doc || document.body.hasAttribute("aria-busy")) return; + const openSeq = state.openSeq; + setBusy(true, "正在验签", null, "正在验签"); + el.verifyForm.inert = true; + try { + const options = { + roots: await securityFiles(el.verifyRoots), certificates: await securityFiles(el.verifyCerts), + timestampRoots: await securityFiles(el.verifyTimeRoots), tokens: await securityFiles(el.verifyTokens), + crls: await securityFiles(el.verifyCRLs), ocsp: await securityFiles(el.verifyOCSP), + requireTimestamp: el.verifyRequireTime.checked, requireRevocation: el.verifyRequireRevocation.checked, + }; + if (openSeq !== state.openSeq || !el.verifyPanel.open) return; + const info = await callWASM("ofdgoVerifySignatures", options); + if (openSeq !== state.openSeq || !el.verifyPanel.open) return; + Object.assign(state.doc, info); + renderMeta(); + el.verifyPanel.close(); + setStatus(info.signatureCount ? "验签完成" : "无签名"); + } catch (err) { + if (openSeq === state.openSeq) el.verifyStatus.textContent = err.message; + } finally { + el.verifyForm.inert = false; + if (openSeq === state.openSeq) setBusy(false); + } +} + +function openExportPanel(encrypted = false) { if (!state.doc || document.body.hasAttribute("aria-busy")) { return; } el.exportForm.reset(); + clearEncryptionForm(); + state.exportEncrypted = encrypted; + el.encryptionFields.hidden = !encrypted; + el.encryptionPassword.required = el.encryptionConfirm.required = encrypted; + updateEncryptionType(); + el.exportPanel.setAttribute("aria-label", encrypted ? "加密另存" : "导出文档"); + el.exportSubmit.textContent = encrypted ? "保存" : "导出"; if (state.editing && state.pageSelection.size) { el.exportSpecified.checked = true; el.exportRange.value = selectedPageRange(); @@ -3600,14 +4000,15 @@ async function updateExportRange() { } } -async function exportFile(whole, indices = null, value = el.exportFormat.value) { +async function exportFile(whole, indices = null, value = el.exportFormat.value, encryption = null) { if (!state.doc || document.body.hasAttribute("aria-busy")) { return; } const saving = value === "ofd"; - if (saving && !state.editorInfo) { + if (saving && !state.editorInfo && !encryption) { return; } + if (!saving && state.doc.encryption?.encrypted && !window.confirm("此格式将输出明文,是否继续?")) return; const format = saving ? { value: "ofd", label: "OFD", extension: "ofd", mime: "application/ofd" } : exportFormatInfo(value); if (!format) { return; @@ -3640,7 +4041,12 @@ async function exportFile(whole, indices = null, value = el.exportFormat.value) openSeq = state.openSeq; setBusy(true, `正在生成 ${label}`, null, status); } - const result = saving ? await callWASM("ofdgoSaveDocument", indices, file) : whole + if (encryption?.recipientFiles) { + encryption.recipients = await encryptionRecipients(encryption.recipientFiles); + delete encryption.recipientFiles; + if (openSeq !== state.openSeq) return; + } + const result = encryption ? await callWASM("ofdgoSaveEncrypted", indices, encryption, file) : saving ? await callWASM("ofdgoSaveDocument", indices, file) : whole ? await callWASM("ofdgoExportDocument", format.value, dpi, indices, state.renderBackend, file) : await callWASM("ofdgoExportPage", pageIndex, format.value, dpi, state.renderBackend, file); if (openSeq !== state.openSeq) { @@ -3649,7 +4055,7 @@ async function exportFile(whole, indices = null, value = el.exportFormat.value) if (result.blob) { downloadBytes(result.blob, result.mime, fileName); } - if (saving && indices === null) { + if (saving && indices === null && state.editorInfo) { state.savedRevision = state.editorInfo.revision; setDirty(false); } @@ -5344,6 +5750,7 @@ function markThumbnailError(index) { } function renderMeta(keepDetails = false) { + renderSecurity(); const doc = state.doc || {}; el.metaPanel.setAttribute("aria-busy", String(!!doc.detailsPending)); document.title = `OFDGo WebUI - ${state.fileName}`; @@ -5440,41 +5847,88 @@ function renderSignatures() { const badges = document.createElement("div"); badges.className = "signature-badges"; - badges.append(fontBadge(signature.status === "valid" ? "通过" : signature.status === "invalid" ? "异常" : "未验", signature.status)); + badges.append(fontBadge(signature.trustedValid ? "可信" : signature.status === "valid" ? "完整" : signature.status === "invalid" ? "异常" : "未验", signature.status)); head.append(badges, name); row.append(head); - appendInfoLine(row, "编号", signature.id); - appendInfoLine(row, "版本", signature.version); - appendInfoLine(row, "章图", signature.sealType); - appendInfoLine(row, "章号", signature.sealId); appendInfoLine(row, "章名", signature.sealName); - appendInfoLine(row, "厂商", signature.sealVendor); - appendInfoLine(row, "签者", signature.signer); + appendInfoLine(row, "签署人", signature.signer); appendInfoLine(row, "时间", formatDocumentTime(signature.signatureDateTime)); appendInfoLine(row, "机构", signatureAgency(signature)); - appendSignatureCheck(row, "原文", signature.dataHashChecked, signature.dataHashOK); - appendSignatureCheck(row, "签名", signature.signedValueChecked, signature.signedValueOK); - if (signature.type !== "Sign") { - appendSignatureCheck(row, "章验", signature.sealChecked, signature.sealOK); - appendSignaturePolicy(row, "一致", signature.sealMatchChecked, signature.sealMatchOK); - } - appendSignatureCheck(row, "证书", signature.certChecked, signature.certOK); - appendSignaturePolicy(row, "签期", signature.signatureTimeChecked, signature.signatureTimeOK); - appendSignaturePolicy(row, "章期", signature.sealTimeChecked, signature.sealTimeOK); - if (signature.sealCertTimeChecked) { - appendInfoLine(row, "制期", signature.sealCertTimeOK ? "有效" : "失效", signature.sealCertTimeOK ? "ok" : ""); - } - appendSignaturePolicy(row, "时效", signature.certTimeChecked, signature.certTimeOK); appendSignatureCheck(row, "信任", signature.certTrustChecked, signature.certTrustOK); - appendInfoLine(row, "保护", signatureReferenceText(signature), signatureReferenceStatus(signature)); - appendInfoLine(row, "算法", signature.signatureMethod); - appendInfoLine(row, "散列", signature.digestMethod); - appendInfoLine(row, "序号", signature.signSerial); - appendInfoLine(row, "主体", signature.signSubject && signature.signSubject !== signature.signer ? signature.signSubject : ""); - appendInfoLine(row, "颁发", signature.signIssuer); - appendInfoLine(row, "章证", signature.sealSubject); + appendInfoLine(row, "保护文件", signatureReferenceText(signature), signatureReferenceStatus(signature)); appendInfoLine(row, "错误", signature.error, "fail"); + const checks = document.createElement("details"); + const checksTitle = document.createElement("summary"); + checksTitle.textContent = "校验详情"; + checks.append(checksTitle); + appendSignatureCheck(checks, "原文", signature.dataHashChecked, signature.dataHashOK); + appendSignatureCheck(checks, "签名", signature.signedValueChecked, signature.signedValueOK); + if (signature.type !== "Sign") { + appendSignatureCheck(checks, "印章", signature.sealChecked, signature.sealOK); + appendSignaturePolicy(checks, "印章匹配", signature.sealMatchChecked, signature.sealMatchOK); + } + appendSignatureCheck(checks, "证书", signature.certChecked, signature.certOK); + appendSignaturePolicy(checks, "签署时间", signature.signatureTimeChecked, signature.signatureTimeOK); + appendSignaturePolicy(checks, "印章期限", signature.sealTimeChecked, signature.sealTimeOK); + if (signature.sealCertTimeChecked) { + appendInfoLine(checks, "制章证书", signature.sealCertTimeOK ? "有效" : "失效", signature.sealCertTimeOK ? "ok" : "fail"); + } + appendSignaturePolicy(checks, "证书期限", signature.certTimeChecked, signature.certTimeOK); + appendSignatureCheck(checks, "策略", signature.policyChecked, signature.policyOK); + appendSignatureCheck(checks, "覆盖", signature.coverageChecked, signature.coverageOK); + appendSignatureCheck(checks, "时间戳", signature.timestampChecked, signature.timestampOK); + appendSignatureCheck(checks, "撤销", signature.revocationChecked, signature.revocationOK); + appendInfoLine(checks, "策略错误", signature.policyError, "fail"); + appendInfoLine(checks, "信任错误", signature.certTrustError, "fail"); + appendInfoLine(checks, "覆盖错误", signature.coverageError, "fail"); + appendInfoLine(checks, "未保护", signature.uncoveredFiles?.join("、"), "fail"); + row.append(checks); + for (const timestamp of signature.timestamps || []) { + const details = document.createElement("details"); + const title = document.createElement("summary"); + title.textContent = timestamp.valid ? "可信时间戳" : "时间戳详情"; + details.append(title); + appendInfoLine(details, timestamp.valid ? "时间" : "声明时间", formatDocumentTime(timestamp.time)); + appendSignatureCheck(details, "绑定", timestamp.bindingChecked, timestamp.bindingOK); + appendSignatureCheck(details, "签名", timestamp.signedValueChecked, timestamp.signedValueOK); + appendSignatureCheck(details, "信任", timestamp.certTrustChecked, timestamp.certTrustOK); + appendSignatureCheck(details, "时效", timestamp.certTimeChecked, timestamp.certTimeOK); + appendInfoLine(details, "错误", timestamp.error, "fail"); + row.append(details); + } + for (const revocation of signature.revocations || []) { + const details = document.createElement("details"); + const title = document.createElement("summary"); + title.textContent = "撤销详情"; + details.append(title); + appendInfoLine(details, "证书", revocation.subject); + appendInfoLine(details, "来源", revocation.source); + const valid = revocation.checked && revocation.ok && revocation.status === "good"; + appendInfoLine(details, "状态", !revocation.checked ? "未验" : valid ? "未撤销" : revocation.status === "revoked" ? "已撤销" : "未知", valid ? "ok" : revocation.checked ? "fail" : ""); + appendInfoLine(details, "更新", formatDocumentTime(revocation.thisUpdate)); + appendInfoLine(details, "截止", formatDocumentTime(revocation.nextUpdate)); + appendInfoLine(details, "撤销时间", formatDocumentTime(revocation.revokedAt)); + appendInfoLine(details, "错误", revocation.error, "fail"); + row.append(details); + } + const details = document.createElement("details"); + const title = document.createElement("summary"); + title.textContent = "签章信息"; + details.append(title); + appendInfoLine(details, "编号", signature.id); + appendInfoLine(details, "版本", signature.version); + appendInfoLine(details, "章图", signature.sealType); + appendInfoLine(details, "章号", signature.sealId); + appendInfoLine(details, "厂商", signature.sealVendor); + appendInfoLine(details, "文档", signature.docRoot); + appendInfoLine(details, "算法", signature.signatureMethod); + appendInfoLine(details, "摘要", signature.digestMethod); + appendInfoLine(details, "序列号", signature.signSerial); + appendInfoLine(details, "证书主体", signature.signSubject); + appendInfoLine(details, "颁发者", signature.signIssuer); + appendInfoLine(details, "制章证书", signature.sealSubject); + row.append(details); fragment.append(row); } el.signatureList.append(fragment); @@ -5493,11 +5947,11 @@ function signatureSummary(signatures) { if (unchecked) { parts.push(`未验 ${unchecked}`); } - return parts.length > 1 ? parts.join(" · ") : `通过 ${signatures.length}`; + return parts.length > 1 ? parts.join(" · ") : `${signatures.every(signature => signature.trustedValid) ? "可信" : "完整"} ${signatures.length}`; } function signatureNameNode(signature) { - const stamps = signature.stamps || []; + const stamps = signature.docIndex ? [] : signature.stamps || []; const name = document.createElement("div"); name.className = "signature-name"; if (!stamps.length) { @@ -6060,6 +6514,7 @@ function updateFitSpace() { } function updateControls() { + renderSecurity(); const hasDoc = Boolean(state.doc); updateRenderBackend(); updateEditorTools(); @@ -6221,11 +6676,11 @@ async function callWASM(name, ...args) { return new Promise((resolve, reject) => { const id = ++wasmRequestID; wasmRequests.set(id, { resolve, reject, openSeq: state.openSeq }); - if (name === "ofdgoExportPage" || name === "ofdgoExportDocument" || name === "ofdgoExportAttachment" || name === "ofdgoSaveDocument" || name === "ofdgoImportPages") { + if (name === "ofdgoExportPage" || name === "ofdgoExportDocument" || name === "ofdgoExportAttachment" || name === "ofdgoSaveDocument" || name === "ofdgoSaveEncrypted" || name === "ofdgoSaveSigned" || name === "ofdgoImportPages") { state.exportRequestID = id; el.cancelExportButton.hidden = false; el.cancelExportButton.disabled = false; - el.cancelExportButton.title = { ofdgoSaveDocument: "取消保存", ofdgoImportPages: "取消导入", ofdgoExportAttachment: "取消下载" }[name] || "取消导出"; + el.cancelExportButton.title = { ofdgoSaveDocument: "取消保存", ofdgoSaveEncrypted: "取消保存", ofdgoSaveSigned: "取消签署", ofdgoImportPages: "取消导入", ofdgoExportAttachment: "取消下载" }[name] || "取消导出"; el.cancelExportButton.setAttribute("aria-label", el.cancelExportButton.title); } try { @@ -6253,6 +6708,7 @@ function showError(err, empty = !state.doc) { function setBusy(busy, text = "", percent = 0, status = "") { document.body.toggleAttribute("aria-busy", busy); + renderSecurity(); updateRenderBackend(); el.editButton.disabled = busy || !state.doc || !state.ready || state.exporting; el.createForm.inert = busy; diff --git a/assets/webui/ofdgo_wasm.js b/assets/webui/ofdgo_wasm.js index ec4aea0..9c0387d 100644 --- a/assets/webui/ofdgo_wasm.js +++ b/assets/webui/ofdgo_wasm.js @@ -7,7 +7,7 @@ self.onmessage = ({ data }) => { operations.get(data.id)?.abort(); return; } - if (data.name === "ofdgoExportPage" || data.name === "ofdgoExportDocument" || data.name === "ofdgoExportAttachment" || data.name === "ofdgoSaveDocument" || data.name === "ofdgoImportPages") { + if (data.name === "ofdgoExportPage" || data.name === "ofdgoExportDocument" || data.name === "ofdgoExportAttachment" || data.name === "ofdgoSaveDocument" || data.name === "ofdgoSaveEncrypted" || data.name === "ofdgoSaveSigned" || data.name === "ofdgoImportPages") { operations.set(data.id, new AbortController()); } pending = pending.then(() => handleMessage(data)); @@ -17,6 +17,10 @@ async function handleMessage({ id, name, args }) { let channel; const signal = operations.get(id)?.signal; const importing = name === "ofdgoImportPages"; + const saving = name === "ofdgoSaveDocument" || name === "ofdgoSaveEncrypted" || name === "ofdgoSaveSigned"; + const key = name === "ofdgoOpen" ? args[3]?.key : name === "ofdgoSaveSigned" || name === "ofdgoLoadImport" ? args[1]?.key : null; + const keyPassword = name === "ofdgoOpen" ? args[3]?.keyPassword : name === "ofdgoSaveSigned" || name === "ofdgoLoadImport" ? args[1]?.keyPassword : null; + const password = name === "ofdgoOpen" ? args[3]?.password : name === "ofdgoSaveEncrypted" || name === "ofdgoLoadImport" ? args[1]?.password : null; try { signal?.throwIfAborted(); const chunks = []; @@ -37,7 +41,7 @@ async function handleMessage({ id, name, args }) { }); } else { const file = args.pop(); - const indices = name === "ofdgoSaveDocument" ? args.shift() : null; + const indices = saving ? args.shift() : null; if (file) output = await file.createWritable(); signal.throwIfAborted(); args.push((bytes, done) => { @@ -58,7 +62,7 @@ async function handleMessage({ id, name, args }) { self.postMessage({ id, type: "export", stage: "pages", completed, total }); finish(done); }); - } else if (name === "ofdgoSaveDocument") { + } else if (saving) { args.push((phase, completed, total, done) => { self.postMessage({ id, type: "export", stage: "prepare", phase, completed, total }); finish(done); @@ -94,6 +98,9 @@ async function handleMessage({ id, name, args }) { } self.postMessage(result); } finally { + password?.fill(0); + key?.fill(0); + keyPassword?.fill(0); channel?.port1.close(); channel?.port2.close(); operations.delete(id); diff --git a/go.mod b/go.mod index df9068f..b51b0ce 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,14 @@ module github.com/xiaoqidun/ofdgo go 1.26.0 require ( + github.com/emmansun/gmsm v0.44.1 + github.com/go-fonts/latin-modern v0.3.3 github.com/go-text/typesetting v0.3.5 github.com/gogpu/gg v0.52.5 github.com/tdewolff/canvas v0.0.0-20260913163248-dd4999d1c76a github.com/tdewolff/font v0.0.0-20260913163313-54f98bb59ee6 github.com/xiaoqidun/jbig2 v1.1.1 + golang.org/x/crypto v0.57.0 golang.org/x/image v0.46.0 golang.org/x/text v0.42.0 ) @@ -22,7 +25,6 @@ require ( github.com/andybalholm/brotli v1.2.2 // indirect github.com/benoitkugler/textlayout v0.3.2 // indirect github.com/benoitkugler/textprocessing v0.0.6 // indirect - github.com/go-fonts/latin-modern v0.3.3 // indirect github.com/gogpu/gpucontext v0.28.0 // indirect github.com/gogpu/gputypes v0.5.2 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect diff --git a/go.sum b/go.sum index bd18ee7..fdea165 100644 --- a/go.sum +++ b/go.sum @@ -28,6 +28,8 @@ github.com/benoitkugler/textlayout v0.3.2/go.mod h1:o+1hFV+JSHBC9qNLIuwVoLedERU7 github.com/benoitkugler/textlayout-testdata v0.1.1/go.mod h1:i/qZl09BbUOtd7Bu/W1CAubRwTWrEXWq6JwMkw8wYxo= github.com/benoitkugler/textprocessing v0.0.6 h1:obkMyj62GEPg3xUVYqROlCN22z1OleuZm6ULqX9Om1g= github.com/benoitkugler/textprocessing v0.0.6/go.mod h1:Io0gN08/PXEzrSOWFa88xHx2Xv3VjvLMY7H76YoI23A= +github.com/emmansun/gmsm v0.44.1 h1:zDTkdtLWFG0vCbhPV+k9pte14tix/eK71At9Iai9fP4= +github.com/emmansun/gmsm v0.44.1/go.mod h1:p6RIUta0/KboFHrOxr1x8q+pd8RZtdaTO7XNp0RmMQM= github.com/go-fonts/latin-modern v0.3.3 h1:g2xNgI8yzdNzIVm+qvbMryB6yGPe0pSMss8QT3QwlJ0= github.com/go-fonts/latin-modern v0.3.3/go.mod h1:tHaiWDGze4EPB0Go4cLT5M3QzRY3peya09Z/8KSCrpY= github.com/go-text/typesetting v0.3.5 h1:XZPUooClHY0Vf/rFyUyuPRNEkawARaFzLMQcXLSEyPk= @@ -76,6 +78,8 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/image v0.0.0-20210504121937-7319ad40d33e/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ= golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew= diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index f0f09d2..1780d4e 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -20,6 +20,7 @@ import ( "bufio" "bytes" "context" + "crypto" "encoding/hex" "encoding/json" "errors" @@ -91,6 +92,7 @@ var copiedStyle *ofdgo.GraphicObject // apiResult 浏览器接口返回结果 type apiResult struct { OK bool `json:"ok"` + Code string `json:"code,omitempty"` Error string `json:"error,omitempty"` Data any `json:"data,omitempty"` MissingGlyphs *ofdgo.MissingGlyphError `json:"missingGlyphs,omitempty"` @@ -102,6 +104,7 @@ func RunWASM() { registerCallback("ofdgoOpen", openDocument) registerCallback("ofdgoConfigure", configureDocument) registerCallback("ofdgoDocumentInfo", documentInfo) + registerCallback("ofdgoVerifySignatures", verifySignatures) registerCallback("ofdgoRenderPage", renderPage) registerCallback("ofdgoRenderBackends", renderBackends) registerCallback("ofdgoSVGFontData", svgFontData) @@ -171,6 +174,8 @@ func RunWASM() { registerCallback("ofdgoUndo", func([]js.Value) (any, error) { return restoreEditor(false) }) registerCallback("ofdgoRedo", func([]js.Value) (any, error) { return restoreEditor(true) }) registerAsyncCallback("ofdgoSaveDocument", saveDocument) + registerAsyncCallback("ofdgoSaveEncrypted", saveEncrypted) + registerAsyncCallback("ofdgoSaveSigned", saveSigned) select {} } @@ -214,6 +219,16 @@ func callbackResult(fn func([]js.Value) (any, error), args []js.Value) any { data, err := safeCall(fn, args) if err != nil { result := apiResult{Error: err.Error()} + switch { + case errors.Is(err, ofdgo.ErrCredentialsRequired): + result.Code, result.Error = "credentialsRequired", "文档已加密" + case errors.Is(err, ofdgo.ErrInvalidCredentials): + result.Code, result.Error = "invalidCredentials", "凭据无效或文件损坏" + case errors.Is(err, ofdgo.ErrUnsupportedEncryption): + result.Code, result.Error = "unsupportedEncryption", "不支持此加密方式" + case errors.Is(err, ofdgo.ErrEncryptionPolicyRequired): + result.Code, result.Error = "encryptionPolicyRequired", "请设置加密方式" + } var failure *ofdgo.EditError if errors.As(err, &failure) { result.ReasonCode = failure.Code @@ -257,6 +272,19 @@ func openDocument(args []js.Value) (any, error) { return nil, err } renderAnnotations := args[2].Bool() + options := OpenOptions{Fonts: fonts, RenderAnnotations: renderAnnotations} + if len(args) > 3 && !args[3].IsNull() && !args[3].IsUndefined() { + credentials, err := credentialsFromJS(args[3]) + if err != nil { + return nil, err + } + defer clear(credentials.Password) + options.ReaderOptions = append(options.ReaderOptions, ofdgo.WithCredentials(credentials)) + } + session, err := Open(data, options) + if err != nil { + return nil, err + } clearImport() currentEditor = nil copiedObjects = nil @@ -265,14 +293,49 @@ func openDocument(args []js.Value) (any, error) { _ = currentSession.Close() currentSession = nil } - session, err := Open(data, OpenOptions{Fonts: fonts, RenderAnnotations: renderAnnotations}) - if err != nil { - return nil, err - } currentSession = session return currentSession.Summary(), nil } +// credentialsFromJS 解析本次打开的口令或本机证书私钥 +// 入参: value 解锁表单数据 +// 返回: ofdgo.Credentials 会话凭据, error 错误信息 +func credentialsFromJS(value js.Value) (ofdgo.Credentials, error) { + credentials := ofdgo.Credentials{UserName: value.Get("userName").String()} + if keyValue := value.Get("key"); !keyValue.IsNull() && !keyValue.IsUndefined() { + key, err := bytesFromJS(keyValue) + if err != nil { + return credentials, err + } + defer clear(key) + certificate, err := bytesFromJS(value.Get("certificate")) + if err != nil { + return credentials, err + } + var password []byte + if field := value.Get("keyPassword"); !field.IsUndefined() && !field.IsNull() { + password, err = bytesFromJS(field) + if err != nil { + return credentials, err + } + defer clear(password) + } + signer, certificate, err := ofdgo.ParseSignatureIdentity(certificate, key, password) + if err != nil { + return credentials, ofdgo.ErrInvalidCredentials + } + decrypter, ok := signer.(crypto.Decrypter) + if !ok { + return credentials, ofdgo.ErrInvalidCredentials + } + credentials.Certificate, credentials.Decrypter = certificate, decrypter + return credentials, nil + } + var err error + credentials.Password, err = bytesFromJS(value.Get("password")) + return credentials, err +} + // configureDocument 更新当前会话的字体和注解设置 // 入参: args 浏览器参数 // 返回: any 文档信息, error 错误信息 @@ -315,6 +378,70 @@ func documentInfo(args []js.Value) (any, error) { return currentSession.Info(), nil } +// securityFilesFromJS 读取会话内的本机证据文件 +// 入参: value 二进制数组列表 +// 返回: [][]byte 文件内容, error 错误信息 +func securityFilesFromJS(value js.Value) ([][]byte, error) { + if value.IsUndefined() || value.IsNull() { + return nil, nil + } + files := make([][]byte, value.Length()) + for i := range files { + var err error + files[i], err = bytesFromJS(value.Index(i)) + if err != nil { + return nil, err + } + } + return files, nil +} + +// verifySignatures 按本机信任锚和离线证据重新验签,不设置联网回调 +// 入参: args 信任根、辅助证书、时间戳及撤销证据 +// 返回: any 分项报告, error 错误信息 +func verifySignatures(args []js.Value) (any, error) { + if currentSession == nil || len(args) == 0 { + return nil, fmt.Errorf("missing signature verification arguments") + } + input := args[0] + files := make(map[string][][]byte) + for _, name := range []string{"roots", "certificates", "timestampRoots", "tokens", "crls", "ocsp"} { + data, err := securityFilesFromJS(input.Get(name)) + if err != nil { + return nil, err + } + files[name] = data + } + now := time.Now() + policy := ofdgo.SignaturePolicy{RejectWeakAlgorithms: true, RequireDocumentCoverage: true, + RequireTimestamp: input.Get("requireTimestamp").Bool(), RequireRevocation: input.Get("requireRevocation").Bool()} + options := []ofdgo.SignatureVerifyOption{ + ofdgo.WithSignatureVerifyTime(now), ofdgo.WithSignaturePolicy(policy), + ofdgo.WithSignatureTrustCerts(files["roots"]...), ofdgo.WithSignatureCerts(files["certificates"]...), + } + if policy.RequireTimestamp || len(files["timestampRoots"])+len(files["tokens"]) > 0 { + options = append(options, ofdgo.WithSignatureTimestamp(ofdgo.SignatureTimestampOptions{ + TrustCerts: files["timestampRoots"], Certificates: files["certificates"], Tokens: files["tokens"], Required: policy.RequireTimestamp, VerifyTime: &now, + })) + } + if policy.RequireRevocation || len(files["crls"])+len(files["ocsp"]) > 0 { + options = append(options, ofdgo.WithSignatureRevocation(ofdgo.SignatureRevocationOptions{ + SignatureRevocationEvidence: ofdgo.SignatureRevocationEvidence{CRLs: files["crls"], OCSPResponses: files["ocsp"]}, + Certificates: append(files["certificates"], files["roots"]...), Required: policy.RequireRevocation, VerifyTime: &now, + })) + } + reports, err := currentSession.Reader.VerifySignatures(options...) + if err != nil { + return nil, err + } + infos := make([]SignatureInfo, len(reports)) + for i, report := range reports { + infos[i] = signatureInfo(report) + } + currentSession.signatures, currentSession.signatureError, currentSession.signaturesRead = infos, nil, true + return map[string]any{"signatures": infos, "signatureCount": len(infos), "signatureError": ""}, nil +} + // exportAttachment 分块导出附件 // 入参: args 浏览器参数 // 返回: any 附件数据, error 错误信息 @@ -2686,7 +2813,16 @@ func loadImport(args []js.Value) (any, error) { if err != nil { return nil, err } - reader, err := ofdgo.NewReader(bytes.NewReader(data), int64(len(data))) + var options []ofdgo.ReaderOption + if len(args) > 1 && !args[1].IsNull() && !args[1].IsUndefined() { + credentials, err := credentialsFromJS(args[1]) + if err != nil { + return nil, err + } + defer clear(credentials.Password) + options = append(options, ofdgo.WithCredentials(credentials)) + } + reader, err := ofdgo.NewReader(bytes.NewReader(data), int64(len(data)), options...) if err != nil { return nil, err } @@ -2700,7 +2836,7 @@ func loadImport(args []js.Value) (any, error) { return nil, fmt.Errorf("document has no pages") } pendingImport = reader - return map[string]any{"pageCount": len(doc.Pages.Page), "signed": doc.Signatures != ""}, nil + return map[string]any{"pageCount": len(doc.Pages.Page), "signed": doc.Signatures != "", "encrypted": reader.Encryption().Encrypted}, nil } // importPages 插入指定来源页面并保持目标文档元数据 @@ -2727,8 +2863,13 @@ func importPages(args []js.Value) (any, error) { } at := args[1].Int() options := ofdgo.PageImportOptions{Outlines: len(args) > 2 && args[2].Bool()} - if len(args) > 3 { - options.OnProgress = editorOperationProgress(args[3]) + progressIndex := 3 + if len(args) > 3 && args[3].Type() == js.TypeBoolean { + options.AllowDecrypted = args[3].Bool() + progressIndex = 4 + } + if len(args) > progressIndex { + options.OnProgress = editorOperationProgress(args[progressIndex]) } if _, err := currentEditor.ImportPagesWithOptions(pendingImport, indexes, at, options); err != nil { return nil, err @@ -3454,16 +3595,172 @@ func saveDocument(args []js.Value) (any, error) { if currentEditor == nil { return nil, fmt.Errorf("no document is being edited") } - if len(args) > 1 { - currentEditor.OnWriteProgress = editorOperationProgress(args[1]) + return saveEditor(currentEditor, args) +} + +// saveEncrypted 以独立快照加密另存,不修改当前文档的输出策略 +// 入参: args 加密选项、写出回调、进度回调和可选页面索引 +// 返回: any 保存结果, error 错误信息 +func saveEncrypted(args []js.Value) (any, error) { + if currentSession == nil || len(args) < 3 { + return nil, fmt.Errorf("missing encrypted save arguments") + } + password, err := bytesFromJS(args[0].Get("password")) + if err != nil { + return nil, err + } + defer clear(password) + options := &ofdgo.EncryptionOptions{Password: password, UserName: args[0].Get("userName").String()} + if recipients := args[0].Get("recipients"); !recipients.IsUndefined() && !recipients.IsNull() { + for i := 0; i < recipients.Length(); i++ { + item := recipients.Index(i) + data, err := bytesFromJS(item.Get("certificate")) + if err != nil { + return nil, err + } + certificate, err := ofdgo.ParseEncryptionCertificate(data) + if err != nil { + return nil, fmt.Errorf("接收者证书无效或不支持SM2加密") + } + options.Recipients = append(options.Recipients, ofdgo.EncryptionRecipient{UserName: item.Get("userName").String(), Certificate: certificate}) + } + } + if len(password) == 0 && len(options.Recipients) == 0 { + return nil, ofdgo.ErrEncryptionPolicyRequired + } + reader := currentSession.Reader + if currentEditor != nil { + reader, err = currentEditor.Reader() + if err != nil { + return nil, err + } + defer reader.Close() + } + editor, err := reader.Editor() + if err != nil { + return nil, err + } + if err := editor.SetEncryption(options); err != nil { + return nil, err + } + return saveEditor(editor, args[1:]) +} + +// saveSigned 调用库层最终包签署并在回验成功后交付浏览器 +// 入参: args 本机证书私钥、信任根、印章位置、写出回调及进度回调 +// 返回: any 保存结果, error 错误信息 +func saveSigned(args []js.Value) (any, error) { + if currentSession == nil || len(args) < 3 { + return nil, fmt.Errorf("missing signature arguments") + } + input := args[0] + key, err := bytesFromJS(input.Get("key")) + if err != nil { + return nil, err + } + defer clear(key) + certificate, err := bytesFromJS(input.Get("certificate")) + if err != nil { + return nil, err + } + var password []byte + if field := input.Get("keyPassword"); !field.IsUndefined() && !field.IsNull() { + password, err = bytesFromJS(field) + if err != nil { + return nil, err + } + defer clear(password) + } + signer, certificate, err := ofdgo.ParseSignatureIdentity(certificate, key, password) + if err != nil { + return nil, fmt.Errorf("证书、私钥或口令无效") + } + roots, err := securityFilesFromJS(input.Get("roots")) + if err != nil { + return nil, err + } + intermediates, err := securityFilesFromJS(input.Get("intermediates")) + if err != nil { + return nil, err + } + options := ofdgo.SignatureWriteOptions{Signer: signer, Certificate: certificate, TrustRoots: roots, + Intermediates: intermediates, Lock: input.Get("lock").Bool()} + switch input.Get("mode").String() { + case "append": + options.Mode = ofdgo.SignatureAppend + case "replace": + options.Mode = ofdgo.SignatureReplace + default: + return nil, fmt.Errorf("invalid signature mode") + } + if seal := input.Get("seal"); !seal.IsNull() && !seal.IsUndefined() { + options.Seal, err = bytesFromJS(seal) + if err != nil { + return nil, err + } + count, err := currentSession.Reader.PageCount() + if err != nil { + return nil, err + } + indices, err := ofdgo.ParsePageRange(input.Get("pages").String(), count) + if err != nil { + return nil, err + } + box := ofdgo.Box{X: input.Get("x").Float(), Y: input.Get("y").Float(), W: input.Get("width").Float(), H: input.Get("height").Float()} + if input.Get("placement").String() == "seam" { + pages := make([]int, len(indices)) + for i, index := range indices { + pages[i] = index + 1 + } + options.Stamps, err = currentSession.Reader.SignatureSeamStamps(pages, box) + if err != nil { + return nil, err + } + } else { + for _, index := range indices { + options.Stamps = append(options.Stamps, ofdgo.SignatureStamp{PageRef: currentSession.doc.Pages.Page[index].ID, + Boundary: fmt.Sprintf("%g %g %g %g", box.X, box.Y, box.W, box.H)}) + } + } + } + progress := editorOperationProgress(args[2]) + if err := progress("sign", 0, 1); err != nil { + return nil, err + } + var output bytes.Buffer + if currentEditor != nil { + currentEditor.OnWriteProgress = progress defer func() { currentEditor.OnWriteProgress = nil }() + _, err = ofdgo.SignEditorTo(&output, currentEditor, options) + } else { + _, err = currentSession.Reader.SignTo(&output, options) + } + if err != nil { + return nil, err + } + if err := progress("sign", 1, 1); err != nil { + return nil, err + } + if _, err := (exportWriter{write: args[1]}).Write(output.Bytes()); err != nil { + return nil, err + } + return successResult(map[string]any{"mime": "application/ofd", "label": "OFD"}), nil +} + +// saveEditor 统一分块写出及进度取消,加密策略由库层执行 +// 入参: editor 待写出文档, args 写出回调、进度回调和可选页面索引 +// 返回: any 保存结果, error 错误信息 +func saveEditor(editor *ofdgo.Editor, args []js.Value) (any, error) { + if len(args) > 1 { + editor.OnWriteProgress = editorOperationProgress(args[1]) + defer func() { editor.OnWriteProgress = nil }() } writer := bufio.NewWriterSize(exportWriter{write: args[0]}, 1<<20) var err error if len(args) > 2 { - _, err = currentEditor.WritePagesTo(writer, indexesFromJS(args[2])) + _, err = editor.WritePagesTo(writer, indexesFromJS(args[2])) } else { - _, err = currentEditor.WriteTo(writer) + _, err = editor.WriteTo(writer) } if err != nil { return nil, err diff --git a/internal/webui/webui.go b/internal/webui/webui.go index 3410982..ed06739 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -30,6 +30,7 @@ import ( type OpenOptions struct { Fonts []FontFile RenderAnnotations bool + ReaderOptions []ofdgo.ReaderOption } // FontFile 字体文件 @@ -40,6 +41,23 @@ type FontInfo = ofdgo.FontInfo // SignatureInfo 签名验证信息 type SignatureInfo struct { + DocIndex int `json:"docIndex"` + DocRoot string `json:"docRoot,omitempty"` + IntegrityValid bool `json:"integrityValid"` + TrustedValid bool `json:"trustedValid"` + PolicyChecked bool `json:"policyChecked"` + PolicyOK bool `json:"policyOK"` + PolicyError string `json:"policyError,omitempty"` + CoverageChecked bool `json:"coverageChecked"` + CoverageOK bool `json:"coverageOK"` + CoverageError string `json:"coverageError,omitempty"` + UncoveredFiles []string `json:"uncoveredFiles,omitempty"` + TimestampChecked bool `json:"timestampChecked"` + TimestampOK bool `json:"timestampOK"` + Timestamps []TimestampInfo `json:"timestamps,omitempty"` + RevocationChecked bool `json:"revocationChecked"` + RevocationOK bool `json:"revocationOK"` + Revocations []RevocationInfo `json:"revocations,omitempty"` ID string `json:"id"` Type string `json:"type"` Status string `json:"status"` @@ -72,6 +90,7 @@ type SignatureInfo struct { CertTimeOK bool `json:"certTimeOK,omitempty"` CertTrustChecked bool `json:"certTrustChecked,omitempty"` CertTrustOK bool `json:"certTrustOK,omitempty"` + CertTrustError string `json:"certTrustError,omitempty"` ReferenceCount int `json:"referenceCount"` ReferenceChecked int `json:"referenceChecked"` ReferencePassed int `json:"referencePassed"` @@ -85,6 +104,34 @@ type SignatureInfo struct { Error string `json:"error,omitempty"` } +// TimestampInfo 独立展示时间戳绑定、签名、信任和证书时效 +type TimestampInfo struct { + Time string `json:"time,omitempty"` + Valid bool `json:"valid"` + BindingChecked bool `json:"bindingChecked"` + BindingOK bool `json:"bindingOK"` + SignedValueChecked bool `json:"signedValueChecked"` + SignedValueOK bool `json:"signedValueOK"` + CertTrustChecked bool `json:"certTrustChecked"` + CertTrustOK bool `json:"certTrustOK"` + CertTimeChecked bool `json:"certTimeChecked"` + CertTimeOK bool `json:"certTimeOK"` + Error string `json:"error,omitempty"` +} + +// RevocationInfo 仅表示签者或制章证书的离线撤销状态 +type RevocationInfo struct { + Subject string `json:"subject,omitempty"` + Source string `json:"source,omitempty"` + Status string `json:"status"` + Checked bool `json:"checked"` + OK bool `json:"ok"` + ThisUpdate string `json:"thisUpdate,omitempty"` + NextUpdate string `json:"nextUpdate,omitempty"` + RevokedAt string `json:"revokedAt,omitempty"` + Error string `json:"error,omitempty"` +} + // SignatureStampInfo 签名外观信息 type SignatureStampInfo struct { ID string `json:"id,omitempty"` @@ -119,6 +166,7 @@ type Session struct { // DocumentInfo 文档信息 type DocumentInfo struct { + Encryption EncryptionInfo `json:"encryption"` Version string `json:"version"` DocType string `json:"docType"` Title string `json:"title"` @@ -139,6 +187,21 @@ type DocumentInfo struct { DetailsPending bool `json:"detailsPending,omitempty"` } +// EncryptionInfo 仅向界面传递非敏感加密状态 +type EncryptionInfo struct { + Encrypted bool `json:"encrypted"` + Method string `json:"method,omitempty"` + Users []string `json:"users,omitempty"` + Layers int `json:"layers,omitempty"` +} + +// encryptionInfo 获取当前文档的加密来源,不包含凭据 +// 返回: EncryptionInfo 加密状态 +func (s *Session) encryptionInfo() EncryptionInfo { + info := s.Reader.Encryption() + return EncryptionInfo{Encrypted: info.Encrypted, Method: info.Method, Users: info.Users, Layers: info.Layers} +} + // OutlineInfo 目录节点信息 type OutlineInfo = ofdgo.OutlineInfo @@ -222,7 +285,7 @@ func Open(data []byte, opts OpenOptions) (*Session, error) { if len(data) == 0 { return nil, fmt.Errorf("empty ofd data") } - reader, err := ofdgo.NewReader(bytes.NewReader(data), int64(len(data))) + reader, err := ofdgo.NewReader(bytes.NewReader(data), int64(len(data)), opts.ReaderOptions...) if err != nil { return nil, err } @@ -381,6 +444,7 @@ func (s *Session) SearchPage(index int, query string) ([]ofdgo.TextMatch, error) // 返回: DocumentInfo 文档信息 func (s *Session) Summary() DocumentInfo { info := DocumentInfo{ + Encryption: s.encryptionInfo(), Version: s.Reader.Version(), DocType: s.Reader.DocType(), PageCount: len(s.doc.Pages.Page), @@ -425,11 +489,12 @@ func (s *Session) Summary() DocumentInfo { // 返回: DocumentInfo 文档信息 func (s *Session) Info() DocumentInfo { info := DocumentInfo{ - Version: s.Reader.Version(), - DocType: s.Reader.DocType(), - PageCount: len(s.doc.Pages.Page), - Pages: make([]PageInfo, 0, len(s.doc.Pages.Page)), - Outlines: s.doc.OutlineInfos(), + Encryption: s.encryptionInfo(), + Version: s.Reader.Version(), + DocType: s.Reader.DocType(), + PageCount: len(s.doc.Pages.Page), + Pages: make([]PageInfo, 0, len(s.doc.Pages.Page)), + Outlines: s.doc.OutlineInfos(), } if attachments, err := s.Reader.Attachments(); err == nil { for _, attachment := range attachments { @@ -613,7 +678,22 @@ func signatureInfo(report ofdgo.SignatureVerifyReport) SignatureInfo { dateTime = report.SignatureTime.Format(time.RFC3339Nano) } checked, passed := signatureReferenceCounts(report.References) - return SignatureInfo{ + info := SignatureInfo{ + DocIndex: report.DocIndex, + DocRoot: report.DocRoot, + IntegrityValid: report.IntegrityValid(), + TrustedValid: report.TrustedValid(), + PolicyChecked: report.PolicyChecked, + PolicyOK: report.PolicyOK, + PolicyError: report.PolicyError, + CoverageChecked: report.CoverageChecked, + CoverageOK: report.CoverageOK, + CoverageError: report.CoverageError, + UncoveredFiles: report.UncoveredFiles, + TimestampChecked: report.TimestampChecked, + TimestampOK: report.TimestampOK, + RevocationChecked: report.RevocationChecked, + RevocationOK: report.RevocationOK, ID: report.ID, Type: string(report.Type), Status: status, @@ -646,6 +726,7 @@ func signatureInfo(report ofdgo.SignatureVerifyReport) SignatureInfo { CertTimeOK: report.CertTimeOK, CertTrustChecked: report.CertTrustChecked, CertTrustOK: report.CertTrustOK, + CertTrustError: report.CertTrustError, ReferenceCount: len(report.References), ReferenceChecked: checked, ReferencePassed: passed, @@ -658,6 +739,33 @@ func signatureInfo(report ofdgo.SignatureVerifyReport) SignatureInfo { Stamps: signatureStampInfos(report.StampPositions), Error: signatureReportError(report), } + for _, stamp := range report.Timestamps { + info.Timestamps = append(info.Timestamps, TimestampInfo{ + Time: securityTime(stamp.Time), Valid: stamp.Valid, + BindingChecked: stamp.BindingChecked, BindingOK: stamp.BindingOK, + SignedValueChecked: stamp.SignedValueChecked, SignedValueOK: stamp.SignedValueOK, + CertTrustChecked: stamp.CertTrustChecked, CertTrustOK: stamp.CertTrustOK, + CertTimeChecked: stamp.CertTimeChecked, CertTimeOK: stamp.CertTimeOK, Error: stamp.Error, + }) + } + for _, revocation := range report.Revocations { + info.Revocations = append(info.Revocations, RevocationInfo{ + Subject: revocation.Certificate.Subject, Source: revocation.Source, Status: revocation.Status, + Checked: revocation.Checked, OK: revocation.OK, ThisUpdate: securityTime(revocation.ThisUpdate), + NextUpdate: securityTime(revocation.NextUpdate), RevokedAt: securityTime(revocation.RevokedAt), Error: revocation.Error, + }) + } + return info +} + +// securityTime 格式化已提供的安全证据时间,零值不展示 +// 入参: value 时间 +// 返回: string 标准时间文本 +func securityTime(value time.Time) string { + if value.IsZero() { + return "" + } + return value.Format(time.RFC3339Nano) } // signatureSigner 获取签名人名称 diff --git a/ofdgo.go b/ofdgo.go index 051eb60..f751ac1 100644 --- a/ofdgo.go +++ b/ofdgo.go @@ -24,9 +24,9 @@ import ( // Open 打开OFD文件 // 返回的阅读器持有文件,使用完毕后需调用Close -// 入参: path 文件路径 +// 入参: path 文件路径, options 阅读选项 // 返回: *Reader 阅读器实例, error 错误信息 -func Open(path string) (*Reader, error) { +func Open(path string, options ...ReaderOption) (*Reader, error) { r, err := zip.OpenReader(path) if err != nil { return nil, err @@ -36,7 +36,7 @@ func Open(path string) (*Reader, error) { Zip: &r.Reader, Closer: r, } - if err := reader.initRoot(); err != nil { + if err := reader.open(options); err != nil { reader.Close() return nil, err } @@ -45,9 +45,9 @@ func Open(path string) (*Reader, error) { // NewReader 从IO读取器创建OFD阅读器 // 读取器的生命周期由调用方管理,Reader.Close不会关闭它 -// 入参: r IO读取器, size 数据大小 +// 入参: r IO读取器, size 数据大小, options 阅读选项 // 返回: *Reader 阅读器实例, error 错误信息 -func NewReader(r io.ReaderAt, size int64) (*Reader, error) { +func NewReader(r io.ReaderAt, size int64, options ...ReaderOption) (*Reader, error) { zr, err := zip.NewReader(r, size) if err != nil { return nil, err @@ -55,7 +55,7 @@ func NewReader(r io.ReaderAt, size int64) (*Reader, error) { reader := &Reader{ Zip: zr, } - if err := reader.initRoot(); err != nil { + if err := reader.open(options); err != nil { return nil, err } return reader, nil diff --git a/ofdgo_crypto.go b/ofdgo_crypto.go new file mode 100644 index 0000000..6e2d32e --- /dev/null +++ b/ofdgo_crypto.go @@ -0,0 +1,137 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "bytes" + "crypto" + "errors" +) + +// 加密错误可通过errors.Is识别,不包含口令或私钥 +var ( + ErrCredentialsRequired = errors.New("document credentials required") + ErrInvalidCredentials = errors.New("invalid credentials or damaged encrypted document") + ErrUnsupportedEncryption = errors.New("unsupported document encryption") + ErrEncryptionPolicyRequired = errors.New("explicit encryption policy required") +) + +// Credentials 解密凭据,UserName为空时尝试匹配用户;证书采用DER编码 +// Password与Decrypter分别用于口令和证书方案,私钥可由外部密码设备持有 +type Credentials struct { + UserName string + Password []byte + Certificate []byte + Decrypter crypto.Decrypter +} + +// CryptoProvider 提供GM/T0099的密码运算,不依赖渲染后端 +// SM4采用CBC模式及PKCS#7填充,PasswordKey采用GB/T32918的SM3密钥派生 +// EncryptKey返回SM2密文的ASN.1编码;签名和私钥解密使用crypto.Signer与crypto.Decrypter +// 提供者不得修改输入字节,返回结果由调用方持有 +type CryptoProvider interface { + EncryptSM4(key, iv, plaintext []byte) ([]byte, error) + DecryptSM4(key, iv, ciphertext []byte) ([]byte, error) + PasswordKey(password []byte) ([]byte, error) + EncryptKey(certificate, key []byte) ([]byte, error) +} + +// ReaderOption 阅读器的凭据与密码提供者选项 +type ReaderOption func(*readerOptions) + +// readerOptions 保存打开文档时的会话参数 +type readerOptions struct { + credentials []Credentials + provider CryptoProvider +} + +// WithCredentials 添加解密凭据,多次调用可解开多层加密 +// 入参: credentials 解密凭据 +// 返回: ReaderOption 阅读选项 +func WithCredentials(credentials Credentials) ReaderOption { + credentials.Password = bytes.Clone(credentials.Password) + credentials.Certificate = bytes.Clone(credentials.Certificate) + return func(options *readerOptions) { + copy := credentials + copy.Password = bytes.Clone(credentials.Password) + copy.Certificate = bytes.Clone(credentials.Certificate) + options.credentials = append(options.credentials, copy) + } +} + +// WithCryptoProvider 设置文档解密的密码提供者 +// 入参: provider 密码提供者,nil使用内置实现 +// 返回: ReaderOption 阅读选项 +func WithCryptoProvider(provider CryptoProvider) ReaderOption { + return func(options *readerOptions) { options.provider = provider } +} + +// EncryptionRecipient 证书加密接收者,Certificate为SM2公钥证书的DER编码 +type EncryptionRecipient struct { + UserName string + UserType string + Certificate []byte +} + +// EncryptionOptions 设置GM/T0099加密,口令与证书方案二选一 +// 口令方案应使用足够长的随机口令;标准密钥派生不提供慢速口令哈希 +type EncryptionOptions struct { + Password []byte + UserName string + UserType string + Recipients []EncryptionRecipient + Provider CryptoProvider +} + +// EncryptionInfo 不含敏感凭据的文档加密信息 +type EncryptionInfo struct { + Encrypted bool + Method string + Users []string + Layers int +} + +// encryptionState 保存解密来源与可继承的输出策略 +type encryptionState struct { + info EncryptionInfo + options *EncryptionOptions +} + +// Encryption 获取文档加密状态,不暴露口令或私钥 +// 返回: EncryptionInfo 加密状态 +func (r *Reader) Encryption() EncryptionInfo { + if r.encryption == nil { + return EncryptionInfo{} + } + info := r.encryption.info + info.Users = append([]string(nil), info.Users...) + return info +} + +// cloneEncryptionOptions 复制可继承的加密策略,密码提供者保持共享 +// 入参: options 加密策略 +// 返回: *EncryptionOptions 独立策略副本 +func cloneEncryptionOptions(options *EncryptionOptions) *EncryptionOptions { + if options == nil { + return nil + } + copy := *options + copy.Password = bytes.Clone(options.Password) + copy.Recipients = append([]EncryptionRecipient(nil), options.Recipients...) + for i := range copy.Recipients { + copy.Recipients[i].Certificate = bytes.Clone(copy.Recipients[i].Certificate) + } + return © +} diff --git a/ofdgo_crypto_editor.go b/ofdgo_crypto_editor.go new file mode 100644 index 0000000..dc22e60 --- /dev/null +++ b/ofdgo_crypto_editor.go @@ -0,0 +1,84 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "bytes" + "io" +) + +// SetEncryption 设置后续保存的加密策略,nil显式选择明文保存 +// 打开单用户口令或证书加密文档时默认继承原策略;多人口令和多层加密需显式选择输出策略 +// 新的加密密钥与初始向量在每次保存时独立生成,设置不改变文档内容或撤销历史 +// 入参: options 加密策略 +// 返回: error 错误信息 +func (e *Editor) SetEncryption(options *EncryptionOptions) error { + if options == nil { + e.encryption = nil + return nil + } + if err := validateEncryptionOptions(*options); err != nil { + return err + } + info := EncryptionInfo{Encrypted: true, Method: "1.1.1", Layers: 1} + if len(options.Password) != 0 { + name := options.UserName + if name == "" { + name = "User" + } + info.Users = []string{name} + } else { + info.Method = "1.1.2" + for _, recipient := range options.Recipients { + info.Users = append(info.Users, recipient.UserName) + } + } + e.encryption = &encryptionState{info: info, options: cloneEncryptionOptions(options)} + return nil +} + +// writeEncrypted 在全部准备及加密成功后写出结果,取消不产生输出 +// 输出流自身写入失败时调用方仍需丢弃本次输出 +// 入参: writer 输出流 +// 返回: int64 写入字节数, error 错误信息 +func (e *Editor) writeEncrypted(writer io.Writer) (int64, error) { + if e.encryption.options == nil { + return 0, ErrEncryptionPolicyRequired + } + var plaintext bytes.Buffer + if _, err := e.writePlaintext(&plaintext); err != nil { + return 0, err + } + defer clear(plaintext.Bytes()) + r, err := NewReader(bytes.NewReader(plaintext.Bytes()), int64(plaintext.Len())) + if err != nil { + return 0, err + } + defer r.Close() + parts, err := r.packageData() + if err != nil { + return 0, err + } + defer func() { + for _, data := range parts { + clear(data) + } + }() + data, err := encryptPackageParts(parts, *e.encryption.options, editorProgress(e.OnWriteProgress)) + if err != nil { + return 0, err + } + return io.Copy(writer, bytes.NewReader(data)) +} diff --git a/ofdgo_crypto_gm.go b/ofdgo_crypto_gm.go new file mode 100644 index 0000000..99d3245 --- /dev/null +++ b/ofdgo_crypto_gm.go @@ -0,0 +1,120 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "bytes" + "crypto/cipher" + "crypto/ecdsa" + "crypto/rand" + "fmt" + + "github.com/emmansun/gmsm/padding" + "github.com/emmansun/gmsm/sm2" + "github.com/emmansun/gmsm/sm3" + "github.com/emmansun/gmsm/sm4" + "github.com/emmansun/gmsm/smx509" +) + +// GMCryptoProvider 使用纯Go国密实现,可在原生环境和WASM使用 +type GMCryptoProvider struct{} + +// EncryptSM4 使用SM4-CBC及PKCS#7填充加密 +// 入参: key 密钥, iv 初始向量, plaintext 明文 +// 返回: []byte 密文, error 错误信息 +func (GMCryptoProvider) EncryptSM4(key, iv, plaintext []byte) ([]byte, error) { + block, err := sm4.NewCipher(key) + if err != nil { + return nil, err + } + if len(iv) != block.BlockSize() { + return nil, fmt.Errorf("invalid SM4 IV length") + } + out := padding.NewPKCS7Padding(sm4.BlockSize).Pad(bytes.Clone(plaintext)) + cipher.NewCBCEncrypter(block, iv).CryptBlocks(out, out) + return out, nil +} + +// DecryptSM4 解密SM4-CBC并校验PKCS#7填充 +// 入参: key 密钥, iv 初始向量, ciphertext 密文 +// 返回: []byte 明文, error 错误信息 +func (GMCryptoProvider) DecryptSM4(key, iv, ciphertext []byte) ([]byte, error) { + block, err := sm4.NewCipher(key) + if err != nil { + return nil, err + } + if len(iv) != block.BlockSize() || len(ciphertext) == 0 || len(ciphertext)%block.BlockSize() != 0 { + return nil, ErrInvalidCredentials + } + out := make([]byte, len(ciphertext)) + cipher.NewCBCDecrypter(block, iv).CryptBlocks(out, ciphertext) + plain, err := padding.NewPKCS7Padding(sm4.BlockSize).ConstantTimeUnpad(out) + if err != nil { + clear(out) + return nil, ErrInvalidCredentials + } + return plain, nil +} + +// PasswordKey 按GB/T32918派生16字节口令密钥 +// 入参: password UTF-8口令 +// 返回: []byte 密钥, error 错误信息 +func (GMCryptoProvider) PasswordKey(password []byte) ([]byte, error) { + if len(password) == 0 { + return nil, ErrCredentialsRequired + } + return sm3.Kdf(password, sm4.BlockSize), nil +} + +// EncryptKey 使用SM2公钥证书封装文件密钥 +// 入参: certificate DER证书, key 文件密钥 +// 返回: []byte ASN.1密文, error 错误信息 +func (GMCryptoProvider) EncryptKey(certificate, key []byte) ([]byte, error) { + cert, err := parseEncryptionCertificate(certificate) + if err != nil { + return nil, err + } + return sm2.EncryptASN1(rand.Reader, cert.PublicKey.(*ecdsa.PublicKey), key) +} + +// ParseEncryptionCertificate 解析单张SM2加密证书并返回DER副本 +// 仅检查格式、算法和密钥用途,接收者身份及信任应由调用方确认 +// 入参: certificate PEM或DER证书 +// 返回: []byte DER证书, error 错误信息 +func ParseEncryptionCertificate(certificate []byte) ([]byte, error) { + cert, err := parseEncryptionCertificate(certificate) + if err != nil { + return nil, err + } + return bytes.Clone(cert.Raw), nil +} + +// parseEncryptionCertificate 解析并检查加密证书 +// 入参: certificate PEM或DER证书 +// 返回: *smx509.Certificate 证书, error 错误信息 +func parseEncryptionCertificate(certificate []byte) (*smx509.Certificate, error) { + cert, err := parseSignatureCertificate(certificate) + if err != nil { + return nil, err + } + public, ok := cert.PublicKey.(*ecdsa.PublicKey) + if !ok || !sm2.IsSM2PublicKey(public) { + return nil, fmt.Errorf("SM2 encryption certificate required") + } + if cert.KeyUsage != 0 && cert.KeyUsage&(smx509.KeyUsageKeyEncipherment|smx509.KeyUsageDataEncipherment|smx509.KeyUsageKeyAgreement) == 0 { + return nil, fmt.Errorf("certificate does not permit encryption") + } + return cert, nil +} diff --git a/ofdgo_crypto_package.go b/ofdgo_crypto_package.go new file mode 100644 index 0000000..e2d630f --- /dev/null +++ b/ofdgo_crypto_package.go @@ -0,0 +1,550 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "archive/zip" + "bytes" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/xml" + "fmt" + "maps" + "slices" + "strings" + "time" +) + +// encryptionList 对应GM/T0099的加密入口 +type encryptionList struct { + XMLName xml.Name `xml:"http://www.ofdspec.org/2016 Encryptions"` + Records []encryptionRecord `xml:"EncryptInfo"` +} + +// encryptionRecord 关联密钥描述和明密文映射 +type encryptionRecord struct { + ID string `xml:"ID,attr"` + Relative string `xml:"Relative,attr,omitempty"` + Provider encryptionProvider `xml:"Provider"` + Scope string `xml:"EncryptScope"` + Date string `xml:"EncryptDate,omitempty"` + Seed string `xml:"DecryptSeedLoc"` + Entries string `xml:"EntriesMapLoc"` +} + +// encryptionProvider 标识生成加密包的组件 +type encryptionProvider struct { + Name string `xml:"Name,attr"` + Version string `xml:"Version,attr"` +} + +// encryptionSeed 保存标准的文件密钥包装信息 +type encryptionSeed struct { + XMLName xml.Name `xml:"http://www.ofdspec.org/2016 DecyptSeed"` + ID string `xml:"ID,attr"` + Method string `xml:"EncryptCaseId,attr"` + Users []encryptionUser `xml:"UserInfo"` + Params encryptionParams `xml:"ExtendParams"` +} + +// encryptionParams 保留未知扩展,避免默认另存时静默丢失其语义 +type encryptionParams struct { + Raw string `xml:",innerxml"` +} + +// encryptionUser 保存单个接收者的公开信息及包装密钥 +type encryptionUser struct { + Name string `xml:"UserName,attr"` + Role string `xml:"UserType,attr,omitempty"` + Certificate string `xml:"UserCert,omitempty"` + Key string `xml:"EncryptedWK"` + IV string `xml:"IVValue"` +} + +// encryptionEntries 保存明密文映射 +type encryptionEntries struct { + XMLName xml.Name + ID string `xml:"ID,attr"` + Entries []encryptionEntry `xml:"EncryptEntry"` +} + +// encryptionEntry 对应一项包内文件加密映射 +type encryptionEntry struct { + Path string `xml:"Path,attr"` + EncryptedPath string `xml:"EPath,attr"` + Seed string `xml:"DecryptSeedLoc,attr,omitempty"` +} + +// open 初始化包索引并在解析OFD根节点前解密 +// 入参: options 阅读选项 +// 返回: error 错误信息 +func (r *Reader) open(options []ReaderOption) error { + settings := readerOptions{provider: GMCryptoProvider{}} + for _, option := range options { + option(&settings) + } + if settings.provider == nil { + settings.provider = GMCryptoProvider{} + } + defer func() { + for i := range settings.credentials { + clear(settings.credentials[i].Password) + } + }() + if err := r.indexPackage(); err != nil { + return err + } + if _, ok := r.fileNamesFold["encryptions.xml"]; ok { + if err := r.decryptPackage(settings); err != nil { + return err + } + } + return r.initRoot() +} + +// packageData 获取包内所有条目的实际字节,保留未识别文件 +// 返回: map[string][]byte 包内条目, error 错误信息 +func (r *Reader) packageData() (map[string][]byte, error) { + parts := make(map[string][]byte, len(r.fileNamesFold)) + for _, name := range r.fileNamesFold { + data, err := r.readFile(name) + if err != nil { + return nil, err + } + parts[name] = data + } + return parts, nil +} + +// decryptPackage 在独立内存包内解密,不修改输入文件 +// 入参: settings 会话凭据和密码实现 +// 返回: error 错误信息 +func (r *Reader) decryptPackage(settings readerOptions) error { + data, err := r.readFile("Encryptions.xml") + if err != nil { + return err + } + var list encryptionList + if err := xml.Unmarshal(data, &list); err != nil || len(list.Records) == 0 { + return fmt.Errorf("%w: invalid Encryptions.xml", ErrUnsupportedEncryption) + } + if len(settings.credentials) == 0 { + return ErrCredentialsRequired + } + parts, err := r.packageData() + if err != nil { + return err + } + state := &encryptionState{info: EncryptionInfo{Encrypted: true, Layers: len(list.Records)}} + list.Records, err = orderEncryptionRecords(list.Records) + if err != nil { + return err + } + for i := len(list.Records) - 1; i >= 0; i-- { + record := list.Records[i] + seedData, seedName, err := encryptionPart(parts, record.Seed) + if err != nil { + return err + } + var seed encryptionSeed + if err := xml.Unmarshal(seedData, &seed); err != nil || seed.ID != record.ID || len(seed.Users) == 0 { + return fmt.Errorf("%w: invalid encryption seed", ErrUnsupportedEncryption) + } + key, iv, credential, err := unwrapEncryptionKey(seed, settings) + if err != nil { + return err + } + mapData, mapName, err := encryptionPart(parts, record.Entries) + var entries encryptionEntries + if err == nil { + entries, err = decodeEncryptionEntries(mapData, key, iv, settings.provider) + } + if err != nil || entries.ID != record.ID || len(entries.Entries) == 0 { + clear(key) + return ErrInvalidCredentials + } + decrypted := make(map[string][]byte, len(entries.Entries)) + used := make(map[string]bool) + extraSeeds := make(map[string]bool) + for _, entry := range entries.Entries { + name := cleanPackagePath(entry.Path) + fold := strings.ToLower(name) + if !validPackagePath(name) || used[fold] { + clear(key) + return fmt.Errorf("ambiguous encrypted package path: %q", entry.Path) + } + entryKey, entryIV := key, iv + if entry.Seed != "" && cleanPackagePath(entry.Seed) != seedName { + seedData, extraSeed, seedErr := encryptionPart(parts, entry.Seed) + var entrySeed encryptionSeed + if seedErr == nil { + seedErr = xml.Unmarshal(seedData, &entrySeed) + } + if seedErr == nil { + entryKey, entryIV, _, seedErr = unwrapEncryptionKey(entrySeed, settings) + } + if seedErr != nil { + clear(key) + return seedErr + } + extraSeeds[extraSeed] = true + } + ciphertext, actual, err := encryptionPart(parts, entry.EncryptedPath) + if err == nil { + decrypted[name], err = settings.provider.DecryptSM4(entryKey, entryIV, ciphertext) + } + if entry.Seed != "" && cleanPackagePath(entry.Seed) != seedName { + clear(entryKey) + } + if err != nil { + clear(key) + return ErrInvalidCredentials + } + delete(parts, actual) + used[fold] = true + } + clear(key) + delete(parts, seedName) + delete(parts, mapName) + for name := range extraSeeds { + delete(parts, name) + } + remaining := make(map[string]string, len(parts)) + for name := range parts { + remaining[strings.ToLower(name)] = name + } + for name, plain := range decrypted { + if existing, ok := remaining[strings.ToLower(name)]; ok { + delete(parts, existing) + } + parts[name] = plain + } + state.info.Method = seed.Method + for _, user := range seed.Users { + state.info.Users = append(state.info.Users, user.Name) + } + if len(list.Records) == 1 && len(extraSeeds) == 0 { + state.options = inheritedEncryptionOptions(seed, credential, settings.provider) + } + } + for name := range parts { + if strings.ToLower(name) == "encryptions.xml" { + delete(parts, name) + } + } + r.Zip, r.files, r.encryption = nil, parts, state + return nil +} + +// decodeEncryptionEntries 读取标准允许的明文或密文明密文映射表 +// 入参: data 映射文件, key 文件密钥, iv 初始向量, provider 密码实现 +// 返回: encryptionEntries 映射表, error 错误信息 +func decodeEncryptionEntries(data, key, iv []byte, provider CryptoProvider) (encryptionEntries, error) { + var entries encryptionEntries + valid := func() bool { + return entries.XMLName.Space == ofdNamespace && (entries.XMLName.Local == "EncryptEntries" || entries.XMLName.Local == "EncryptedEntries") + } + if err := xml.Unmarshal(data, &entries); err == nil && valid() { + return entries, nil + } + plain, err := provider.DecryptSM4(key, iv, data) + if err != nil { + return encryptionEntries{}, err + } + defer clear(plain) + entries = encryptionEntries{} + if err := xml.Unmarshal(plain, &entries); err != nil || !valid() { + return encryptionEntries{}, ErrInvalidCredentials + } + return entries, nil +} + +// encryptionPart 按统一大小写规则读取加密元数据引用 +// 入参: parts 包内条目, name 引用路径 +// 返回: []byte 条目内容, string 实际路径, error 错误信息 +func encryptionPart(parts map[string][]byte, name string) ([]byte, string, error) { + name = cleanPackagePath(name) + if !validPackagePath(name) { + return nil, "", fmt.Errorf("invalid encryption path: %q", name) + } + if data, ok := parts[name]; ok { + return data, name, nil + } + for actual, data := range parts { + if strings.ToLower(actual) == strings.ToLower(name) { + return data, actual, nil + } + } + return nil, "", fmt.Errorf("missing encrypted entry: %s", name) +} + +// orderEncryptionRecords 按前驱关系排序加密层,拒绝环和缺失的前驱 +// 入参: records 加密记录 +// 返回: []encryptionRecord 从内到外的顺序, error 错误信息 +func orderEncryptionRecords(records []encryptionRecord) ([]encryptionRecord, error) { + index := make(map[string]encryptionRecord, len(records)) + for _, record := range records { + if _, exists := index[record.ID]; exists || record.ID == "" { + return nil, fmt.Errorf("%w: duplicate encryption operation", ErrUnsupportedEncryption) + } + index[record.ID] = record + } + states := make(map[string]uint8, len(records)) + ordered := make([]encryptionRecord, 0, len(records)) + for _, record := range records { + chain := []encryptionRecord{} + for record.ID != "" && states[record.ID] != 2 { + if states[record.ID] == 1 { + return nil, fmt.Errorf("%w: cyclic encryption operations", ErrUnsupportedEncryption) + } + states[record.ID] = 1 + chain = append(chain, record) + if record.Relative == "" { + break + } + previous, ok := index[record.Relative] + if !ok { + return nil, fmt.Errorf("%w: missing encryption predecessor", ErrUnsupportedEncryption) + } + record = previous + } + for i := len(chain) - 1; i >= 0; i-- { + states[chain[i].ID] = 2 + ordered = append(ordered, chain[i]) + } + } + return ordered, nil +} + +// unwrapEncryptionKey 按方案尝试会话凭据,不向错误信息写入口令 +// 入参: seed 密钥描述, settings 会话凭据 +// 返回: []byte 文件密钥, []byte 初始向量, Credentials 命中凭据, error 错误信息 +func unwrapEncryptionKey(seed encryptionSeed, settings readerOptions) ([]byte, []byte, Credentials, error) { + if seed.Method != "1.1.1" && seed.Method != "1.1.2" { + return nil, nil, Credentials{}, ErrUnsupportedEncryption + } + for _, credential := range settings.credentials { + for _, user := range seed.Users { + if credential.UserName != "" && credential.UserName != user.Name { + continue + } + wrapped, err := base64.StdEncoding.DecodeString(user.Key) + if err != nil { + return nil, nil, Credentials{}, ErrInvalidCredentials + } + iv, err := base64.StdEncoding.DecodeString(user.IV) + if user.IV == "" { + iv = make([]byte, 16) + } + if err != nil || len(iv) != 16 { + return nil, nil, Credentials{}, ErrInvalidCredentials + } + var key []byte + if seed.Method == "1.1.1" && len(credential.Password) != 0 { + kek, keyErr := settings.provider.PasswordKey(credential.Password) + if keyErr != nil { + return nil, nil, Credentials{}, keyErr + } + key, err = settings.provider.DecryptSM4(kek, iv, wrapped) + clear(kek) + } else if seed.Method == "1.1.2" && credential.Decrypter != nil { + certificate, decodeErr := base64.StdEncoding.DecodeString(user.Certificate) + if decodeErr != nil || len(certificate) == 0 { + return nil, nil, Credentials{}, ErrInvalidCredentials + } + if len(credential.Certificate) != 0 && !bytes.Equal(credential.Certificate, certificate) { + continue + } + key, err = credential.Decrypter.Decrypt(rand.Reader, wrapped, nil) + } else { + continue + } + if err == nil && len(key) == 16 { + return key, iv, credential, nil + } + clear(key) + } + } + return nil, nil, Credentials{}, ErrInvalidCredentials +} + +// inheritedEncryptionOptions 保留能够完整恢复的原接收者策略 +// 入参: seed 原密钥描述, credential 命中凭据, provider 密码实现 +// 返回: *EncryptionOptions 可继承策略,nil表示需调用方显式选择 +func inheritedEncryptionOptions(seed encryptionSeed, credential Credentials, provider CryptoProvider) *EncryptionOptions { + if strings.TrimSpace(seed.Params.Raw) != "" { + return nil + } + options := &EncryptionOptions{Provider: provider} + if seed.Method == "1.1.1" { + if len(seed.Users) != 1 { + return nil + } + options.UserName, options.UserType, options.Password = seed.Users[0].Name, seed.Users[0].Role, bytes.Clone(credential.Password) + return options + } + for _, user := range seed.Users { + certificate, err := base64.StdEncoding.DecodeString(user.Certificate) + if err != nil { + return nil + } + options.Recipients = append(options.Recipients, EncryptionRecipient{UserName: user.Name, UserType: user.Role, Certificate: certificate}) + } + return options +} + +// EncryptPackage 对完整OFD包进行GM/T0099加密,保留全部条目的原始内容 +// 先签后加密可保留对明文的验签能力;出错不返回部分密文,已有加密包须先解密 +// 入参: data 明文OFD包, options 加密策略 +// 返回: []byte 加密OFD包, error 错误信息 +func EncryptPackage(data []byte, options EncryptionOptions) ([]byte, error) { + r, err := NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return nil, err + } + defer r.Close() + parts, err := r.packageData() + if err != nil { + return nil, err + } + return encryptPackageParts(parts, options, nil) +} + +// encryptPackageParts 加密所有原条目,再生成标准入口、密钥描述与映射 +// 入参: parts 明文条目, options 加密策略, progress 取消与进度检查 +// 返回: []byte 加密包, error 错误信息 +func encryptPackageParts(parts map[string][]byte, options EncryptionOptions, progress editorProgress) ([]byte, error) { + if err := validateEncryptionOptions(options); err != nil { + return nil, err + } + provider := options.Provider + if provider == nil { + provider = GMCryptoProvider{} + } + key, iv, identifier := make([]byte, 16), make([]byte, 16), make([]byte, 16) + for _, buffer := range [][]byte{key, iv, identifier} { + if _, err := rand.Read(buffer); err != nil { + return nil, err + } + } + defer clear(key) + id := "E" + hex.EncodeToString(identifier) + seed := encryptionSeed{ID: id, Method: "1.1.1"} + if len(options.Password) != 0 { + kek, err := provider.PasswordKey(options.Password) + if err != nil { + return nil, err + } + wrapped, err := provider.EncryptSM4(kek, iv, key) + clear(kek) + if err != nil { + return nil, err + } + name := options.UserName + if name == "" { + name = "User" + } + seed.Users = []encryptionUser{{Name: name, Role: options.UserType, Key: base64.StdEncoding.EncodeToString(wrapped), IV: base64.StdEncoding.EncodeToString(iv)}} + } else { + seed.Method = "1.1.2" + for _, recipient := range options.Recipients { + certificate, err := ParseEncryptionCertificate(recipient.Certificate) + if err != nil { + return nil, err + } + wrapped, err := provider.EncryptKey(certificate, key) + if err != nil { + return nil, err + } + seed.Users = append(seed.Users, encryptionUser{Name: recipient.UserName, Role: recipient.UserType, Certificate: base64.StdEncoding.EncodeToString(certificate), Key: base64.StdEncoding.EncodeToString(wrapped), IV: base64.StdEncoding.EncodeToString(iv)}) + } + } + entries := encryptionEntries{XMLName: xml.Name{Space: ofdNamespace, Local: "EncryptEntries"}, ID: id} + cipherParts := make(map[string][]byte, len(parts)+3) + for i, name := range slices.Sorted(maps.Keys(parts)) { + if err := progress.report("encrypt", i, len(parts)); err != nil { + return nil, err + } + encryptedName := fmt.Sprintf("Encrypt/Files/%d.dat", i) + data, err := provider.EncryptSM4(key, iv, parts[name]) + if err != nil { + return nil, err + } + cipherParts[encryptedName] = data + entries.Entries = append(entries.Entries, encryptionEntry{Path: "/" + name, EncryptedPath: "/" + encryptedName}) + } + mapData, err := xml.Marshal(entries) + if err != nil { + return nil, err + } + mapData, err = provider.EncryptSM4(key, iv, mapData) + if err != nil { + return nil, err + } + cipherParts["Encrypt/Entries.dat"] = mapData + seedData, err := xml.Marshal(seed) + if err != nil { + return nil, err + } + cipherParts["Encrypt/DecryptSeed.dat"] = seedData + list := encryptionList{Records: []encryptionRecord{{ID: id, Provider: encryptionProvider{Name: "OFDGo", Version: "1.0"}, Scope: "All", Date: time.Now().Format(time.RFC3339), Seed: "/Encrypt/DecryptSeed.dat", Entries: "/Encrypt/Entries.dat"}}} + listData, err := xml.Marshal(list) + if err != nil { + return nil, err + } + cipherParts["Encryptions.xml"] = listData + var output bytes.Buffer + archive := zip.NewWriter(&output) + for _, name := range slices.Sorted(maps.Keys(cipherParts)) { + entry, err := archive.CreateHeader(&zip.FileHeader{Name: name, Method: zip.Store}) + if err != nil { + return nil, err + } + if _, err := entry.Write(cipherParts[name]); err != nil { + return nil, err + } + } + if err := archive.Close(); err != nil { + return nil, err + } + if err := progress.report("encrypt", len(parts), len(parts)); err != nil { + return nil, err + } + return output.Bytes(), nil +} + +// validateEncryptionOptions 校验输出方案,禁止空口令或混用口令与证书方案 +// 入参: options 加密策略 +// 返回: error 错误信息 +func validateEncryptionOptions(options EncryptionOptions) error { + if (len(options.Password) != 0) == (len(options.Recipients) != 0) { + return fmt.Errorf("choose password or certificate encryption") + } + if options.UserType != "" && options.UserType != "User" && options.UserType != "Owner" { + return fmt.Errorf("invalid encryption user type") + } + seen := make(map[string]bool) + for _, recipient := range options.Recipients { + if recipient.UserName == "" || seen[recipient.UserName] || len(recipient.Certificate) == 0 { + return fmt.Errorf("invalid encryption recipient") + } + if recipient.UserType != "" && recipient.UserType != "User" && recipient.UserType != "Owner" { + return fmt.Errorf("invalid encryption recipient type") + } + seen[recipient.UserName] = true + } + return nil +} diff --git a/ofdgo_editor.go b/ofdgo_editor.go index 056cbe9..103864d 100644 --- a/ofdgo_editor.go +++ b/ofdgo_editor.go @@ -61,6 +61,7 @@ type Editor struct { source *editorSource origins map[string]*editorObjectOrigin outlines []byte + encryption *encryptionState } // SetFontFS 设置编辑与几何度量使用的外部字体来源,不嵌入或替换文档字体 diff --git a/ofdgo_editor_import.go b/ofdgo_editor_import.go index ff0014e..98035ba 100644 --- a/ofdgo_editor_import.go +++ b/ofdgo_editor_import.go @@ -62,9 +62,11 @@ type editorPageImport struct { // PageImportOptions 页面导入选项,零值仅导入页面及其关联资源 // OnProgress按ids、pages、resources、commit阶段回报进度,total为0表示总量未知 // 回调返回错误则取消导入,commit检查点之后统一提交;回调不可重入修改编辑器 +// AllowDecrypted显式允许将加密来源导入未加密目标,不影响原文件 type PageImportOptions struct { - Outlines bool - OnProgress func(stage string, completed, total int) error + Outlines bool + AllowDecrypted bool + OnProgress func(stage string, completed, total int) error } // ImportPages 将来源主文档中的指定页面插入目标位置,保持输入顺序并作为一次撤销操作 @@ -83,6 +85,9 @@ func (e *Editor) ImportPages(source *Reader, indexes []int, at int) ([]string, e // 入参: source 来源阅读器, indexes 来源页面索引, at 目标插入位置, options 导入选项 // 返回: []string 新页面标识, error 错误信息 func (e *Editor) ImportPagesWithOptions(source *Reader, indexes []int, at int, options PageImportOptions) ([]string, error) { + if source.encryption != nil && e.encryption == nil && !options.AllowDecrypted { + return nil, ErrEncryptionPolicyRequired + } return e.importPages(source, indexes, at, false, options) } diff --git a/ofdgo_editor_open.go b/ofdgo_editor_open.go index af4d06f..01cc277 100644 --- a/ofdgo_editor_open.go +++ b/ofdgo_editor_open.go @@ -116,17 +116,7 @@ type PageCapabilities struct { // 页面按需解析,未修改条目直接保留;编辑器及其Reader快照使用期间不得关闭输入Reader // 返回: *Editor 编辑器, error 错误信息 func (r *Reader) Editor() (*Editor, error) { - if r.Zip != nil { - seen := make(map[string]bool) - for _, file := range r.Zip.File { - name := cleanPackagePath(file.Name) - if seen[name] { - return nil, fmt.Errorf("ambiguous package entry %q", file.Name) - } - seen[name] = true - } - } - reader := &Reader{Zip: r.Zip, files: r.files} + reader := &Reader{Zip: r.Zip, files: r.files, encryption: r.encryption} if err := reader.initRoot(); err != nil { return nil, err } @@ -143,6 +133,7 @@ func (r *Reader) Editor() (*Editor, error) { return nil, err } e := NewEditor() + e.encryption = r.encryption e.Info, e.maxID = cloneEditorData(*info), maximum directory := cleanPackagePath(reader.RootDir) e.source = &editorSource{reader: reader, document: doc, info: cloneEditorData(*info), directory: directory, pages: make(map[string]*editorSourcePage), origins: make(map[string]*editorObjectOrigin)} diff --git a/ofdgo_editor_package.go b/ofdgo_editor_package.go index ef284cf..726b496 100644 --- a/ofdgo_editor_package.go +++ b/ofdgo_editor_package.go @@ -538,7 +538,7 @@ func (e *Editor) sourceReader(progress editorProgress) (*Reader, error) { files = make(map[string][]byte) } maps.Copy(files, parts) - reader := &Reader{Zip: e.source.reader.Zip, files: files} + reader := &Reader{Zip: e.source.reader.Zip, files: files, encryption: e.encryption} if err := reader.initRoot(); err != nil { return nil, err } diff --git a/ofdgo_reader.go b/ofdgo_reader.go index 4ec69c2..054531e 100644 --- a/ofdgo_reader.go +++ b/ofdgo_reader.go @@ -49,6 +49,8 @@ type Reader struct { fileIndex map[string]*zip.File fileIndexFold map[string]*zip.File files map[string][]byte + fileNamesFold map[string]string + encryption *encryptionState } // Close 关闭阅读器 @@ -63,17 +65,8 @@ func (r *Reader) Close() error { // initRoot 读取根节点信息 // 返回: error 错误信息 func (r *Reader) initRoot() error { - r.fileIndex = make(map[string]*zip.File) - r.fileIndexFold = make(map[string]*zip.File) - if r.Zip != nil { - for _, f := range r.Zip.File { - name := cleanPackagePath(f.Name) - r.fileIndex[name] = f - fold := strings.ToLower(name) - if _, ok := r.fileIndexFold[fold]; !ok { - r.fileIndexFold[fold] = f - } - } + if err := r.indexPackage(); err != nil { + return err } data, err := r.readFile("OFD.xml") if err != nil { @@ -96,11 +89,58 @@ func (r *Reader) initRoot() error { return nil } +// indexPackage 建立包路径索引,拒绝会使显示与验签产生歧义的同名条目 +// 返回: error 错误信息 +func (r *Reader) indexPackage() error { + r.fileIndex = make(map[string]*zip.File) + r.fileIndexFold = make(map[string]*zip.File) + r.fileNamesFold = make(map[string]string) + if r.Zip != nil { + for _, f := range r.Zip.File { + if f.FileInfo().IsDir() { + continue + } + name := cleanPackagePath(f.Name) + if !validPackagePath(name) { + return fmt.Errorf("invalid package path: %q", f.Name) + } + fold := strings.ToLower(name) + if previous, ok := r.fileIndexFold[fold]; ok { + return fmt.Errorf("ambiguous package paths: %q and %q", previous.Name, f.Name) + } + r.fileIndex[name] = f + r.fileIndexFold[fold] = f + r.fileNamesFold[fold] = name + } + } + for name := range r.files { + if name != cleanPackagePath(name) || !validPackagePath(name) { + return fmt.Errorf("invalid package path: %q", name) + } + fold := strings.ToLower(name) + if previous, ok := r.fileNamesFold[fold]; ok && previous != name { + return fmt.Errorf("ambiguous package paths: %q and %q", previous, name) + } + r.fileNamesFold[fold] = name + } + return nil +} + +// validPackagePath 判断规范化路径是否位于包内 +// 入参: name 规范化路径 +// 返回: bool 是否有效 +func validPackagePath(name string) bool { + return name != "." && name != ".." && !strings.HasPrefix(name, "../") && !strings.HasPrefix(name, "/") && !strings.ContainsAny(name, ":\x00") +} + // readFile 读取文档内的文件 // 入参: name 文件名 // 返回: []byte 文件内容, error 错误信息 func (r *Reader) readFile(name string) ([]byte, error) { name = cleanPackagePath(name) + if actual, ok := r.fileNamesFold[strings.ToLower(name)]; ok { + name = actual + } if data, ok := r.files[name]; ok { return bytes.Clone(data), nil } @@ -118,6 +158,9 @@ func (r *Reader) readFile(name string) ([]byte, error) { // 返回: io.ReadCloser 文件流, error 错误信息 func (r *Reader) openFile(name string) (io.ReadCloser, error) { name = cleanPackagePath(name) + if actual, ok := r.fileNamesFold[strings.ToLower(name)]; ok { + name = actual + } if data, ok := r.files[name]; ok { return io.NopCloser(bytes.NewReader(data)), nil } diff --git a/ofdgo_sign_gbt.go b/ofdgo_sign_gbt.go index 53fcf0c..ae9ed64 100644 --- a/ofdgo_sign_gbt.go +++ b/ofdgo_sign_gbt.go @@ -29,6 +29,7 @@ const ( // digitalVerifyResult 数字签名验证结果 type digitalVerifyResult struct { + Timestamps []signatureTimestampEvidence DataHashChecked bool DataHashOK bool SignedChecked bool @@ -42,6 +43,7 @@ type digitalVerifyResult struct { // gbtSignedData GB/T 35275 SignedData结构 type gbtSignedData struct { + Version int ContentDigest []byte Certs []gbtCertificate Signers []gbtSignerInfo @@ -56,6 +58,8 @@ type gbtCertificate struct { // gbtSignerInfo SignedData签名者信息 type gbtSignerInfo struct { + Version int + UnauthAttrs asn1.RawValue Issuer []byte Serial *big.Int DigestAlg string @@ -70,6 +74,15 @@ type gbtSignerInfo struct { // 返回: *digitalVerifyResult 验证结果, error 错误信息 func verifyDigitalSignature(method, digestMethod string, signedValue, signedData []byte, options *signatureVerifyOptions) (*digitalVerifyResult, error) { if value, ok := normalizeGBT35275SignedValue(signedValue); ok { + sd, err := parseGBT35275SignedData(value) + if err != nil { + return nil, err + } + for _, signer := range sd.Signers { + if method != "" && !signatureAlgorithmEquivalent(method, signer.SignatureAlg, signer.DigestAlg) { + return nil, fmt.Errorf("signature method does not match SignedData") + } + } return verifyGBT35275SignedData(value, signedData, options) } if isSM2SignatureMethod(method) { @@ -99,7 +112,7 @@ func verifyRawDigitalSignature(signedValue, signedData []byte, options *signatur result.SignerCerts = [][]byte{cert} result.CertInfo = signatureCertInfo(cert) result.SignedChecked = true - if sm2VerifySignature(pub, nil, signedData, signedValue) { + if verifySM2Signature(pub, nil, signedData, signedValue) { result.SignedOK = true return result, nil } @@ -144,6 +157,15 @@ func verifyGBT35275SignedData(signedValue, signedData []byte, options *signature if err != nil { return nil, err } + if options.Policy != nil && len(options.Policy.SignedDataVersions) != 0 { + allowed := false + for _, v := range options.Policy.SignedDataVersions { + allowed = allowed || v == sd.Version + } + if !allowed { + return nil, rejectSignaturePolicy("SignedData version rejected: %d", sd.Version) + } + } for _, cert := range options.SignCerts { c, err := parseGBTCertificate(cert) if err == nil { @@ -155,6 +177,12 @@ func verifyGBT35275SignedData(signedValue, signedData []byte, options *signature } result := &digitalVerifyResult{Certs: sd.rawCerts()} for index, signer := range sd.Signers { + if err := signatureSignerDigest(signer.SignatureAlg, signer.DigestAlg); err != nil { + return result, err + } + if err := signatureAlgorithmPolicy(options.Policy, signer.SignatureAlg, signer.DigestAlg); err != nil { + return result, err + } digest, err := signatureDigest(signer.DigestAlg, signedData) if err != nil { return nil, err @@ -184,6 +212,13 @@ func verifyGBT35275SignedData(signedValue, signedData []byte, options *signature return result, nil } result.SignerCerts = append(result.SignerCerts, cert.Raw) + tokens, err := signatureTimestampAttributes(signer.UnauthAttrs) + if err != nil { + return result, err + } + for _, token := range tokens { + result.Timestamps = append(result.Timestamps, signatureTimestampEvidence{Token: token, Data: signer.Signature}) + } result.CertInfo = signatureCertInfo(cert.Raw) if isSM2SignatureMethod(signer.SignatureAlg) { pub, err := parseSM2PublicKeyFromCert(cert.Raw) @@ -191,7 +226,7 @@ func verifyGBT35275SignedData(signedValue, signedData []byte, options *signature result.CertOK = false return result, err } - if !sm2VerifySignature(pub, nil, plain, signer.Signature) { + if !verifySM2Signature(pub, nil, plain, signer.Signature) { result.SignedChecked = true result.CertChecked = index == len(sd.Signers)-1 result.CertOK = true @@ -376,7 +411,11 @@ func parseGBT35275SignedData(data []byte) (*gbtSignedData, error) { if !ok || len(items) < 4 { return nil, fmt.Errorf("invalid signed data") } - sd := &gbtSignedData{} + version, err := asn1Integer(items[0]) + if err != nil || version != 1 && version != 3 { + return nil, fmt.Errorf("unsupported GB/T signed data version: %d", version) + } + sd := &gbtSignedData{Version: version} contentOID, content, hasContent, err := parseGBTContentInfo(items[2]) if err != nil { return nil, err @@ -431,7 +470,7 @@ func parseGBTContentInfoBytes(data []byte) (string, asn1.RawValue, bool, error) // 返回: string 内容类型, asn1.RawValue 内容, bool 是否存在内容, error 错误信息 func parseGBTContentInfo(raw asn1.RawValue) (string, asn1.RawValue, bool, error) { items, ok := asn1Children(raw.Bytes) - if !ok || len(items) == 0 { + if !ok || len(items) == 0 || len(items) > 2 || raw.Class != asn1.ClassUniversal || raw.Tag != asn1.TagSequence || !raw.IsCompound { return "", asn1.RawValue{}, false, fmt.Errorf("invalid content info") } oid, err := asn1OIDString(items[0]) @@ -477,8 +516,8 @@ func parseGBTCertificate(data []byte) (gbtCertificate, error) { } return gbtCertificate{ Raw: cert.Raw, - Issuer: cert.Issuer, - Serial: cert.Serial, + Issuer: cert.RawIssuer, + Serial: cert.SerialNumber, }, nil } @@ -509,6 +548,10 @@ func parseGBTSignerInfo(raw asn1.RawValue) (gbtSignerInfo, error) { if !ok || len(items) < 5 { return gbtSignerInfo{}, fmt.Errorf("invalid signer info") } + version, err := asn1Integer(items[0]) + if err != nil || version != 1 { + return gbtSignerInfo{}, fmt.Errorf("unsupported signer info version") + } issuer, serial, err := parseGBTIssuerAndSerial(items[1]) if err != nil { return gbtSignerInfo{}, err @@ -539,7 +582,16 @@ func parseGBTSignerInfo(raw asn1.RawValue) (gbtSignerInfo, error) { if err != nil { return gbtSignerInfo{}, err } + var unauth asn1.RawValue + if len(items) > idx+2 { + unauth = items[idx+2] + if len(items) != idx+3 || unauth.Class != asn1.ClassContextSpecific || unauth.Tag != 1 || !unauth.IsCompound { + return gbtSignerInfo{}, fmt.Errorf("invalid unauthenticated attributes") + } + } return gbtSignerInfo{ + Version: version, + UnauthAttrs: unauth, Issuer: issuer, Serial: serial, DigestAlg: digestAlg, @@ -587,20 +639,29 @@ func parseGBTMessageDigestAttr(raw asn1.RawValue) ([]byte, error) { if !ok { return nil, fmt.Errorf("invalid authenticated attributes") } + var digest []byte + found := false for _, attr := range attrs { items, ok := asn1Children(attr.Bytes) - if !ok || len(items) < 2 { - continue + if !ok || len(items) != 2 { + return nil, fmt.Errorf("invalid authenticated attribute") } oid, err := asn1OIDString(items[0]) if err != nil || oid != signAttrMessageDigest { continue } values, ok := asn1Children(items[1].Bytes) - if !ok || len(values) == 0 { + if !ok || len(values) != 1 || found { return nil, fmt.Errorf("invalid message digest attribute") } - return asn1OctetString(values[0]) + digest, err = asn1OctetString(values[0]) + if err != nil { + return nil, err + } + found = true + } + if found { + return digest, nil } return nil, fmt.Errorf("message digest attribute not found") } diff --git a/ofdgo_sign_policy.go b/ofdgo_sign_policy.go new file mode 100644 index 0000000..3112f73 --- /dev/null +++ b/ofdgo_sign_policy.go @@ -0,0 +1,366 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "bytes" + "crypto" + "encoding/xml" + "errors" + "fmt" + "path" + "sort" + "strings" +) + +// SignaturePolicy 签名验签策略 +// 空列表不限制已支持的算法或版本, RequiredFiles使用包内绝对路径 +// RequireDocumentCoverage检查OFD.xml、文档目录及已知OFD引用可达的跨目录文件 +// 仅排除当前Signature.xml和SignedValue及签名列表, 外置印章也须有引用摘要 +// 此范围不表示历史修订范围或整个多文档包, 自定义扩展隐含引用应使用RequiredFiles补充 +type SignaturePolicy struct { + OFDVersions []string + SESVersions []int + SignedDataVersions []int + DigestMethods []string + SignatureMethods []string + RejectWeakAlgorithms bool + RequiredFiles []string + RequireDocumentCoverage bool + RequireTimestamp bool + RequireRevocation bool +} + +// signaturePolicyRejection 区分策略拒绝与解析失败 +type signaturePolicyRejection struct{ message string } + +// Error 返回策略拒绝原因 +// 返回: string 错误信息 +func (e signaturePolicyRejection) Error() string { return e.message } + +// rejectSignaturePolicy 构造可识别的策略拒绝错误 +// 入参: format 消息模板, values 模板参数 +// 返回: error 策略错误 +func rejectSignaturePolicy(format string, values ...any) error { + return signaturePolicyRejection{message: fmt.Sprintf(format, values...)} +} + +// applySignaturePolicyError 将实际算法策略拒绝记入报告 +// 入参: err 验签错误 +func (report *SignatureVerifyReport) applySignaturePolicyError(err error) { + var rejection signaturePolicyRejection + if errors.As(err, &rejection) { + report.PolicyChecked, report.PolicyOK, report.PolicyError = true, false, rejection.Error() + } +} + +// WithSignaturePolicy 设置签名策略 +// 入参: policy 签名策略 +// 返回: SignatureVerifyOption 验签选项 +func WithSignaturePolicy(policy SignaturePolicy) SignatureVerifyOption { + policy.OFDVersions = append([]string(nil), policy.OFDVersions...) + policy.SESVersions = append([]int(nil), policy.SESVersions...) + policy.SignedDataVersions = append([]int(nil), policy.SignedDataVersions...) + policy.DigestMethods = append([]string(nil), policy.DigestMethods...) + policy.SignatureMethods = append([]string(nil), policy.SignatureMethods...) + policy.RequiredFiles = append([]string(nil), policy.RequiredFiles...) + return func(o *signatureVerifyOptions) { o.Policy = &policy } +} + +// applySignaturePolicy 应用版本和算法限制 +// 入参: o 验签选项, ofdVersion OFD版本, sesVersion SES版本, method 签名算法, digest 摘要算法 +func (report *SignatureVerifyReport) applySignaturePolicy(o *signatureVerifyOptions, ofdVersion string, sesVersion int, method, digest string) { + if o.Policy == nil { + return + } + if !report.PolicyChecked { + report.PolicyChecked, report.PolicyOK = true, true + } + p := o.Policy + var err error + if len(p.OFDVersions) != 0 && !signatureStringAllowed(ofdVersion, p.OFDVersions) { + err = fmt.Errorf("OFD version rejected: %s", ofdVersion) + } + if sesVersion != 0 && len(p.SESVersions) != 0 { + allowed := false + for _, v := range p.SESVersions { + allowed = allowed || v == sesVersion + } + if !allowed { + err = fmt.Errorf("SES version rejected: %d", sesVersion) + } + } + if e := signatureAlgorithmPolicy(p, method, digest); e != nil { + err = e + } + if err != nil { + report.PolicyOK, report.PolicyError = false, err.Error() + } +} + +// signatureAlgorithmPolicy 检查实际签名和摘要算法 +// 入参: p 策略, method 签名算法, digest 摘要算法 +// 返回: error 策略拒绝原因 +func signatureAlgorithmPolicy(p *SignaturePolicy, method, digest string) error { + if p == nil { + return nil + } + if len(p.DigestMethods) != 0 { + allowed := false + for _, v := range p.DigestMethods { + allowed = allowed || signatureDigestEquivalent(v, digest) + } + if !allowed { + return rejectSignaturePolicy("digest algorithm rejected: %s", digest) + } + } + if len(p.SignatureMethods) != 0 { + allowed := false + for _, v := range p.SignatureMethods { + allowed = allowed || signatureAlgorithmEquivalent(v, method, digest) + } + if !allowed { + return rejectSignaturePolicy("signature algorithm rejected: %s", method) + } + } + if p.RejectWeakAlgorithms { + h, ok := signatureDigestHash(digest) + if ok && (h == crypto.MD5 || h == crypto.SHA1) { + return rejectSignaturePolicy("weak digest algorithm: %s", digest) + } + h, err := signatureMethodHash(method, digest) + if !isSM2SignatureMethod(method) && err == nil && (h == crypto.MD5 || h == crypto.SHA1) { + return rejectSignaturePolicy("weak signature algorithm: %s", method) + } + } + return nil +} + +// signatureStringAllowed 检查值是否在允许列表中 +// 入参: value 待检查值, values 允许列表 +// 返回: bool 是否允许 +func signatureStringAllowed(value string, values []string) bool { + for _, v := range values { + if v == value { + return true + } + } + return false +} + +// signatureDigestEquivalent 比较摘要算法别名与OID +// 入参: a 第一算法, b 第二算法 +// 返回: bool 是否为相同算法 +func signatureDigestEquivalent(a, b string) bool { + if isSM3DigestMethod(a) && isSM3DigestMethod(b) { + return true + } + x, ok := signatureDigestHash(a) + y, ok2 := signatureDigestHash(b) + return ok && ok2 && x == y +} + +// signatureAlgorithmEquivalent 比较签名算法别名与OID +// 入参: a 第一算法, b 第二算法, digest 摘要算法 +// 返回: bool 是否为相同算法 +func signatureAlgorithmEquivalent(a, b, digest string) bool { + if isSM2SignatureMethod(a) && isSM2SignatureMethod(b) { + return true + } + if !(isRSASignatureMethod(a) && isRSASignatureMethod(b) || isECDSASignatureMethod(a) && isECDSASignatureMethod(b)) { + return false + } + x, err := signatureMethodHash(a, digest) + y, err2 := signatureMethodHash(b, digest) + return err == nil && err2 == nil && x == y +} + +// signatureSignerDigest 验证SignerInfo中的摘要与签名算法一致 +// 入参: method 签名算法, digest 摘要算法 +// 返回: error 不一致时的错误 +func signatureSignerDigest(method, digest string) error { + if isSM2SignatureMethod(method) { + if !isSM3DigestMethod(digest) { + return fmt.Errorf("SM2 requires SM3 signer digest") + } + return nil + } + h, err := signatureMethodHash(method, digest) + d, ok := signatureDigestHash(digest) + if err != nil || !ok || h != d { + return fmt.Errorf("signer digest and signature algorithm mismatch") + } + return nil +} + +// applySignatureCoverage 区分引用摘要通过和文档覆盖完整 +// 入参: report 验签报告, listPath 签名列表路径, o 验签选项 +func (r *Reader) applySignatureCoverage(report *SignatureVerifyReport, listPath string, o *signatureVerifyOptions) { + covered := make(map[string]bool) + for _, ref := range report.References { + if ref.Checked && ref.OK { + covered[r.signatureCoveragePath(ref.Path)] = true + } + } + for name := range covered { + report.CoveredFiles = append(report.CoveredFiles, name) + } + sort.Strings(report.CoveredFiles) + if o.Policy == nil || !o.Policy.RequireDocumentCoverage && len(o.Policy.RequiredFiles) == 0 { + return + } + report.CoverageChecked, report.CoverageOK = true, true + required := make(map[string]bool) + for _, name := range o.Policy.RequiredFiles { + required[r.signatureCoveragePath(name)] = true + } + if o.Policy.RequireDocumentCoverage { + required[r.signatureCoveragePath("OFD.xml")] = true + excluded := map[string]bool{r.signatureCoveragePath(listPath): true} + name := r.signatureCoveragePath(signatureRefPath(listPath, report.BaseLoc)) + data, err := r.readFile(name) + if err != nil { + report.CoverageOK = false + report.CoverageError = err.Error() + return + } + file, err := parseSignatureFile(data) + if err != nil { + report.CoverageOK = false + report.CoverageError = err.Error() + return + } + excluded[name] = true + if file.SignedValue != "" { + excluded[r.signatureCoveragePath(signatureRefPath(name, file.SignedValue))] = true + } + root := path.Dir(r.signatureCoveragePath(o.DocRoot)) + include := func(name string) { + if (root == "." || strings.HasPrefix(name, root+"/")) && !excluded[name] && !strings.HasSuffix(name, "/") { + required[name] = true + } + } + for name, file := range r.fileIndex { + if !file.FileInfo().IsDir() { + include(name) + } + } + for name := range r.files { + include(name) + } + // 可达文件始终必需, 不允许伪装为签名载体后排除 + if err := r.signatureCoverageReferences(o.DocRoot, required); err != nil { + report.CoverageOK, report.CoverageError = false, err.Error() + } + } + for name := range required { + if !covered[name] { + report.UncoveredFiles = append(report.UncoveredFiles, name) + } + } + sort.Strings(report.UncoveredFiles) + report.CoverageOK = report.CoverageOK && len(report.UncoveredFiles) == 0 +} + +// signatureCoveragePath 复用阅读器大小写索引获取同一文件身份 +// 入参: name 包内路径 +// 返回: string 实际包内路径 +func (r *Reader) signatureCoveragePath(name string) string { + name = cleanPackagePath(name) + if actual, ok := r.fileNamesFold[strings.ToLower(name)]; ok { + return actual + } + return name +} + +// signatureCoverageNode 收集OFD结构中的包内路径引用 +type signatureCoverageNode struct { + XMLName xml.Name + Attrs []xml.Attr `xml:",any,attr"` + Text string `xml:",chardata"` + Children []signatureCoverageNode `xml:",any"` +} + +// signatureCoverageReferences 收集标准OFD引用的传递闭包且包含跨文档资源 +// 入参: root 文档入口, required 待补充的必需文件集合 +// 返回: error 引用文件无法读取或解析时的错误 +func (r *Reader) signatureCoverageReferences(root string, required map[string]bool) error { + queue := []string{r.signatureCoveragePath(root)} + seen := make(map[string]bool) + for len(queue) != 0 { + name := queue[0] + queue = queue[1:] + if seen[name] { + continue + } + seen[name], required[name] = true, true + data, err := r.readFile(name) + if err != nil { + return err + } + if !bytes.HasPrefix(bytes.TrimSpace(data), []byte("<")) { + continue + } + var node signatureCoverageNode + if err := xml.Unmarshal(data, &node); err != nil { + return fmt.Errorf("coverage XML %s: %w", name, err) + } + base := "" + if node.XMLName.Local == "Res" { + for _, attr := range node.Attrs { + if attr.Name.Local == "BaseLoc" { + base = attr.Value + } + } + } + add := func(loc string, resource bool) { + if strings.TrimSpace(loc) == "" { + return + } + resBase := "" + if resource { + resBase = base + } + resolved := r.signatureCoveragePath(resolveResourcePath(name, resBase, loc)) + required[resolved] = true + if !seen[resolved] { + queue = append(queue, resolved) + } + } + var walk func(signatureCoverageNode) + walk = func(current signatureCoverageNode) { + kind := current.XMLName.Local + if kind == "Signatures" || kind == "Signature" { + return + } + for _, attr := range current.Attrs { + if attr.Name.Local == "BaseLoc" && kind != "Res" || attr.Name.Local == "Link" && kind == "DrawParam" { + add(attr.Value, node.XMLName.Local == "Res") + } + } + switch kind { + case "PublicRes", "DocumentRes", "PageRes", "Annotations", "Attachments", "CustomTags", "Extensions", "FileLoc", "SchemaLoc": + add(current.Text, false) + case "FontFile", "MediaFile", "Profile": + add(current.Text, true) + } + for _, child := range current.Children { + walk(child) + } + } + walk(node) + } + return nil +} diff --git a/ofdgo_sign_revocation.go b/ofdgo_sign_revocation.go new file mode 100644 index 0000000..0133423 --- /dev/null +++ b/ofdgo_sign_revocation.go @@ -0,0 +1,435 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "bytes" + "crypto" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "fmt" + "time" + + "github.com/emmansun/gmsm/smx509" + "golang.org/x/crypto/ocsp" +) + +// SignatureRevocationEvidence 离线吊销证据 +type SignatureRevocationEvidence struct { + CRLs [][]byte + OCSPResponses [][]byte +} + +// SignatureRevocationRequest 外部吊销证据请求 +type SignatureRevocationRequest struct { + Certificate []byte + Issuer []byte + VerifyTime time.Time +} + +// SignatureRevocationOptions 离线吊销检查选项 +// 检查签署者及制章者证书, 不代表整条证书链或TSA的吊销状态 +// Fetch仅提供DER证据, 本库不主动联网, 缺少NextUpdate时必须显式设置MaxAge +// VerifyTime缺省取验签指定时间或当前时间, 不采用签名自报时间 +type SignatureRevocationOptions struct { + SignatureRevocationEvidence + Certificates [][]byte + Fetch func(SignatureRevocationRequest) (SignatureRevocationEvidence, error) + Required bool + VerifyTime *time.Time + MaxAge time.Duration +} + +// SignatureRevocationReport 单张证书吊销检查结果 +// Checked仅表示已取得经验证的状态结论, OK仅在状态为good时为true +type SignatureRevocationReport struct { + Certificate SignatureCertInfo + Source string + Status string + Checked bool + OK bool + ThisUpdate time.Time + NextUpdate time.Time + RevokedAt time.Time + Error string +} + +// WithSignatureRevocation 设置离线吊销证据检查 +// 入参: options 吊销选项 +// 返回: SignatureVerifyOption 验签选项 +func WithSignatureRevocation(options SignatureRevocationOptions) SignatureVerifyOption { + options.CRLs = cloneSignatureEvidence(options.CRLs) + options.OCSPResponses = cloneSignatureEvidence(options.OCSPResponses) + options.Certificates = appendSignatureCerts(nil, options.Certificates...) + if options.VerifyTime != nil { + t := *options.VerifyTime + options.VerifyTime = &t + } + return func(o *signatureVerifyOptions) { o.Revocation = &options } +} + +// VerifySignatureRevocation 验证单张证书的离线吊销证据 +// 入参: cert 证书DER, issuer 直接颁发者DER, options 吊销选项 +// 返回: SignatureRevocationReport 验证结果 +func VerifySignatureRevocation(cert, issuer []byte, options SignatureRevocationOptions) SignatureRevocationReport { + result := SignatureRevocationReport{Certificate: signatureCertInfo(cert), Status: "unknown"} + c, err := parseSignatureCertificate(cert) + if err != nil { + result.Error = err.Error() + return result + } + i, err := parseSignatureCertificate(issuer) + if err != nil || !i.IsCA || i.KeyUsage != 0 && i.KeyUsage&smx509.KeyUsageCertSign == 0 || !bytes.Equal(c.RawIssuer, i.RawSubject) { + result.Error = "invalid revocation issuer" + return result + } + valid, err := verifyCertificateSignature(c, issuer) + if err != nil || !valid { + result.Error = "certificate issuer signature mismatch" + return result + } + at := time.Now() + if options.VerifyTime != nil { + at = *options.VerifyTime + } + if !timeInRange(at, i.NotBefore, i.NotAfter) || len(i.UnhandledCriticalExtensions) != 0 { + result.Error = "invalid revocation issuer validity" + return result + } + evidence := options.SignatureRevocationEvidence + if options.Fetch != nil { + fetched, err := options.Fetch(SignatureRevocationRequest{Certificate: bytes.Clone(cert), Issuer: bytes.Clone(issuer), VerifyTime: at}) + if err != nil { + result.Error = err.Error() + return result + } + evidence.CRLs = append(cloneSignatureEvidence(evidence.CRLs), fetched.CRLs...) + evidence.OCSPResponses = append(cloneSignatureEvidence(evidence.OCSPResponses), fetched.OCSPResponses...) + } + var good *SignatureRevocationReport + for _, raw := range evidence.CRLs { + item := verifySignatureCRL(raw, cert, issuer, at, options.MaxAge) + if item.Checked && item.Status == "revoked" { + return item + } + if item.OK { + copy := item + good = © + } else if item.Error != "" { + result.Error = item.Error + } + } + for _, raw := range evidence.OCSPResponses { + item := verifySignatureOCSP(raw, cert, issuer, at, options.MaxAge) + if item.Checked && item.Status == "revoked" { + return item + } + if item.OK { + copy := item + good = © + } else if item.Error != "" { + result.Error = item.Error + } + if item.Checked && item.Status == "unknown" && !result.Checked { + result = item + } + } + if good != nil { + return *good + } + if result.Error == "" { + result.Error = "usable revocation evidence not found" + } + return result +} + +// signatureEvidenceFresh 检查证据时间窗口及最大年龄 +// 入参: thisUpdate 生效时间, nextUpdate 下次更新时间, at 验证时间, maxAge 最大年龄 +// 返回: bool 证据是否新鲜 +func signatureEvidenceFresh(thisUpdate, nextUpdate, at time.Time, maxAge time.Duration) bool { + if thisUpdate.IsZero() || thisUpdate.After(at) || maxAge < 0 { + return false + } + if maxAge > 0 && at.Sub(thisUpdate) > maxAge { + return false + } + if nextUpdate.IsZero() { + return maxAge > 0 + } + return nextUpdate.After(thisUpdate) && at.Before(nextUpdate) +} + +// verifySignatureCRL 验证直接完整CRL及目标序列号 +// 入参: raw CRL字节, cert 证书, issuer 颁发者, at 验证时间, maxAge 最大年龄 +// 返回: SignatureRevocationReport 吊销结果 +func verifySignatureCRL(raw, cert, issuer []byte, at time.Time, maxAge time.Duration) (result SignatureRevocationReport) { + result = SignatureRevocationReport{Certificate: signatureCertInfo(cert), Source: "CRL", Status: "unknown"} + err := func() error { + crl, err := smx509.ParseRevocationList(raw) + if err != nil { + return err + } + parent, err := parseSignatureCertificate(issuer) + if err != nil { + return err + } + if !bytes.Equal(crl.RawIssuer, parent.RawSubject) { + return fmt.Errorf("CRL issuer mismatch") + } + if len(crl.AuthorityKeyId) != 0 && !bytes.Equal(crl.AuthorityKeyId, parent.SubjectKeyId) { + return fmt.Errorf("CRL authority key mismatch") + } + if err := crl.CheckSignatureFrom(parent); err != nil { + return err + } + for _, ext := range crl.Extensions { + if ext.Id.String() == "2.5.29.27" || ext.Id.String() == "2.5.29.28" { + return fmt.Errorf("delta or scoped CRL not supported") + } + if ext.Critical { + return fmt.Errorf("unsupported critical CRL extension") + } + } + result.ThisUpdate, result.NextUpdate = crl.ThisUpdate, crl.NextUpdate + if !signatureEvidenceFresh(crl.ThisUpdate, crl.NextUpdate, at, maxAge) { + return fmt.Errorf("stale or future CRL") + } + c, err := parseSignatureCertificate(cert) + if err != nil { + return err + } + for _, entry := range crl.RevokedCertificateEntries { + for _, ext := range entry.Extensions { + if ext.Critical || ext.Id.String() == "2.5.29.29" { + return fmt.Errorf("unsupported CRL entry extension") + } + } + if entry.SerialNumber.Cmp(c.SerialNumber) == 0 { + if entry.ReasonCode == 8 { + return fmt.Errorf("removeFromCRL requires delta processing") + } + if entry.RevocationTime.After(crl.ThisUpdate) { + return fmt.Errorf("CRL revocation time is in the future") + } + result.Checked, result.Status, result.RevokedAt = true, "revoked", entry.RevocationTime + return nil + } + } + result.Checked, result.OK, result.Status = true, true, "good" + return nil + }() + if err != nil { + result.Error = err.Error() + } + return result +} + +// verifySignatureOCSP 验证OCSP签名、授权、颁发者绑定及状态 +// 入参: raw 响应DER, cert 证书, issuer 颁发者, at 验证时间, maxAge 最大年龄 +// 返回: SignatureRevocationReport 吊销结果 +func verifySignatureOCSP(raw, cert, issuer []byte, at time.Time, maxAge time.Duration) (result SignatureRevocationReport) { + result = SignatureRevocationReport{Certificate: signatureCertInfo(cert), Source: "OCSP", Status: "unknown"} + err := func() error { + c, err := x509.ParseCertificate(cert) + if err != nil { + return fmt.Errorf("unsupported OCSP certificate: %w", err) + } + parent, err := x509.ParseCertificate(issuer) + if err != nil { + return err + } + response, err := ocsp.ParseResponseForCert(raw, c, parent) + if err != nil { + return err + } + if err := verifyOCSPCertID(response.TBSResponseData, c, parent, response.IssuerHash); err != nil { + return err + } + responder := parent + if response.Certificate != nil && !bytes.Equal(response.Certificate.Raw, parent.Raw) { + responder = response.Certificate + if !bytes.Equal(responder.RawIssuer, parent.RawSubject) || responder.CheckSignatureFrom(parent) != nil { + return fmt.Errorf("OCSP responder is not issued by certificate issuer") + } + allowed := false + for _, usage := range responder.ExtKeyUsage { + allowed = allowed || usage == x509.ExtKeyUsageOCSPSigning + } + if !allowed || responder.KeyUsage != 0 && responder.KeyUsage&x509.KeyUsageDigitalSignature == 0 { + return fmt.Errorf("unauthorized OCSP responder") + } + } + if len(responder.UnhandledCriticalExtensions) != 0 || !timeInRange(at, responder.NotBefore, responder.NotAfter) || !timeInRange(response.ProducedAt, responder.NotBefore, responder.NotAfter) { + return fmt.Errorf("OCSP responder certificate not valid") + } + if len(response.RawResponderName) != 0 && !bytes.Equal(response.RawResponderName, responder.RawSubject) { + return fmt.Errorf("OCSP responder name mismatch") + } + if len(response.ResponderKeyHash) != 0 { + var spki struct { + Algorithm pkix.AlgorithmIdentifier + Key asn1.BitString + } + if _, err := asn1.Unmarshal(responder.RawSubjectPublicKeyInfo, &spki); err != nil { + return err + } + if !bytes.Equal(response.ResponderKeyHash, signatureHashBytes(crypto.SHA1, spki.Key.Bytes)) { + return fmt.Errorf("OCSP responder key mismatch") + } + } + result.ThisUpdate, result.NextUpdate, result.RevokedAt = response.ThisUpdate, response.NextUpdate, response.RevokedAt + if !signatureEvidenceFresh(response.ThisUpdate, response.NextUpdate, at, maxAge) || response.ProducedAt.After(at) || response.ProducedAt.Before(response.ThisUpdate) { + return fmt.Errorf("stale or future OCSP response") + } + switch response.Status { + case ocsp.Good: + result.Checked, result.OK, result.Status = true, true, "good" + case ocsp.Revoked: + if response.RevokedAt.IsZero() || response.RevokedAt.After(response.ThisUpdate) { + return fmt.Errorf("invalid OCSP revocation time") + } + result.Checked, result.Status = true, "revoked" + case ocsp.Unknown: + result.Checked = true + default: + return fmt.Errorf("unsupported OCSP status") + } + return nil + }() + if err != nil { + result.Error = err.Error() + } + return result +} + +// verifyOCSPCertID 补充校验x/crypto未验证的颁发者摘要和重复CertID +// 入参: tbs 响应待签名数据, cert 证书, issuer 颁发者, hash CertID摘要算法 +// 返回: error 绑定错误 +func verifyOCSPCertID(tbs []byte, cert, issuer *x509.Certificate, hash crypto.Hash) error { + var data struct { + Version int `asn1:"optional,explicit,tag:0"` + Responder asn1.RawValue + ProducedAt time.Time `asn1:"generalized"` + Responses []asn1.RawValue + Extensions []pkix.Extension `asn1:"optional,explicit,tag:1"` + } + rest, err := asn1.Unmarshal(tbs, &data) + if err != nil || len(rest) != 0 || data.Version != 0 { + return fmt.Errorf("invalid OCSP response data") + } + for _, ext := range data.Extensions { + if ext.Critical { + return fmt.Errorf("unsupported critical OCSP extension") + } + } + var spki struct { + Algorithm pkix.AlgorithmIdentifier + Key asn1.BitString + } + if _, err := asn1.Unmarshal(issuer.RawSubjectPublicKeyInfo, &spki); err != nil { + return err + } + matched := 0 + for _, raw := range data.Responses { + fields, ok := asn1Children(raw.Bytes) + if !ok || len(fields) < 3 { + return fmt.Errorf("invalid OCSP single response") + } + id, ok := asn1Children(fields[0].Bytes) + if !ok || len(id) != 4 { + return fmt.Errorf("invalid OCSP CertID") + } + serial, err := asn1IntegerBig(id[3]) + if err != nil { + return err + } + if serial.Cmp(cert.SerialNumber) != 0 { + continue + } + matched++ + nameHash, err := asn1OctetString(id[1]) + if err != nil { + return err + } + keyHash, err := asn1OctetString(id[2]) + if err != nil { + return err + } + if !bytes.Equal(nameHash, signatureHashBytes(hash, issuer.RawSubject)) || !bytes.Equal(keyHash, signatureHashBytes(hash, spki.Key.Bytes)) { + return fmt.Errorf("OCSP issuer hash mismatch") + } + } + if matched != 1 { + return fmt.Errorf("ambiguous OCSP certificate status") + } + return nil +} + +// applySignatureRevocation 检查签署者及制章者的离线吊销证据 +// 入参: o 验签选项, certs 目标证书, extra 辅助证书 +func (report *SignatureVerifyReport) applySignatureRevocation(o *signatureVerifyOptions, certs, extra [][]byte) { + var options SignatureRevocationOptions + if o.Revocation != nil { + options = *o.Revocation + } + if o.Policy != nil { + options.Required = options.Required || o.Policy.RequireRevocation + } + if o.Revocation == nil && !options.Required { + return + } + at := time.Now() + if o.VerifyTime != nil { + at = *o.VerifyTime + } + if options.VerifyTime != nil { + at = *options.VerifyTime + } + options.VerifyTime = &at + pool := appendSignatureCerts(nil, options.Certificates...) + pool = append(pool, extra...) + pool = append(pool, o.SignCerts...) + pool = append(pool, o.TrustCerts...) + pool = compactSignatureCerts(pool) + for _, raw := range compactSignatureCerts(certs) { + result := SignatureRevocationReport{Certificate: signatureCertInfo(raw), Status: "unknown", Error: "revocation issuer not found"} + cert, err := parseSignatureCertificate(raw) + if err == nil { + for _, issuerRaw := range pool { + issuer, err := parseSignatureCertificate(issuerRaw) + if err != nil || !bytes.Equal(cert.RawIssuer, issuer.RawSubject) { + continue + } + ok, err := verifyCertificateSignature(cert, issuerRaw) + if err != nil || !ok { + continue + } + result = VerifySignatureRevocation(raw, issuerRaw, options) + break + } + } + report.Revocations = append(report.Revocations, result) + } + if len(report.Revocations) != 0 { + report.RevocationChecked, report.RevocationOK = true, true + for _, result := range report.Revocations { + report.RevocationOK = report.RevocationOK && result.Checked && result.OK + } + } + if options.Required && !report.RevocationChecked { + report.PolicyChecked, report.PolicyOK, report.PolicyError = true, false, "required revocation evidence missing" + } +} diff --git a/ofdgo_sign_ses.go b/ofdgo_sign_ses.go index 619961b..61689e4 100644 --- a/ofdgo_sign_ses.go +++ b/ofdgo_sign_ses.go @@ -16,11 +16,14 @@ package ofdgo import ( "bytes" + "crypto/ecdsa" "encoding/asn1" "fmt" - "math/big" "strings" "time" + + "github.com/emmansun/gmsm/sm2" + "github.com/emmansun/gmsm/sm3" ) const ( @@ -30,14 +33,12 @@ const ( signMethodSM2SM3 = "1.2.156.10197.1.501" signMethodSM2SM3B = "1.2.156.10197.501" signMethodSM2Sign = "1.2.156.10197.1.301.1" - signCurveSM2P256 = "1.2.156.10197.1.301" - signECPublicKey = "1.2.840.10045.2.1" signASN1Sequence = 16 - signPublicKeySize = 65 ) // sesSignature SES签章值 type sesSignature struct { + Timestamp []byte ToSign []byte Cert []byte SignAlg string @@ -49,15 +50,16 @@ type sesSignature struct { // sesSeal SES电子印章 type sesSeal struct { - Raw []byte - SignData []byte - Cert []byte - SignAlg string - Signature []byte - PicType string - PicData []byte - CertList sesCertList - Info SignatureSealInfo + TimestampRaw []byte + Raw []byte + SignData []byte + Cert []byte + SignAlg string + Signature []byte + PicType string + PicData []byte + CertList sesCertList + Info SignatureSealInfo } // sesCertList SES印章证书列表 @@ -74,6 +76,8 @@ type sesCertDigest struct { // sesVerifyResult SES签章验证结果 type sesVerifyResult struct { + SignatureMethod string + Timestamps []signatureTimestampEvidence DataHashChecked bool DataHashOK bool SignedChecked bool @@ -147,7 +151,22 @@ func parseSESSignature(data []byte) (*sesSignature, error) { if err != nil { return nil, err } + if version != 4 { + return nil, fmt.Errorf("unsupported ses version: %d", version) + } + var timestamp []byte + if len(items) == 5 { + value, parseErr := asn1Explicit(items[4]) + if parseErr != nil { + return nil, fmt.Errorf("invalid ses timestamp tag: %w", parseErr) + } + timestamp, err = asn1BitStringBytes(value) + if err != nil || len(timestamp) == 0 { + return nil, fmt.Errorf("invalid ses timestamp") + } + } return &sesSignature{ + Timestamp: timestamp, ToSign: append([]byte(nil), items[0].FullBytes...), Cert: cert, SignAlg: alg, @@ -175,7 +194,7 @@ func parseSESSignatureV1(items []asn1.RawValue) (*sesSignature, error) { return nil, err } version, err := asn1Integer(tbsItems[0]) - if err != nil || version != seal.Info.Version { + if err != nil || version != seal.Info.Version || version != 1 { return nil, fmt.Errorf("invalid ses version") } signatureTime := parseSESSignatureTimeV1(tbsItems[2]) @@ -233,7 +252,16 @@ func verifySESSignature(data, signedData []byte, options *signatureVerifyOptions if !isSM2SignatureMethod(sig.SignAlg) || !isSM2SignatureMethod(sig.Seal.SignAlg) { return nil, fmt.Errorf("unsupported signature method") } + for _, method := range []string{sig.SignAlg, sig.Seal.SignAlg} { + if err := signatureAlgorithmPolicy(options.Policy, method, "SM3"); err != nil { + return nil, err + } + } result := &sesVerifyResult{} + result.SignatureMethod = sig.SignAlg + if len(sig.Timestamp) != 0 { + result.Timestamps = append(result.Timestamps, signatureTimestampEvidence{Token: sig.Timestamp, Data: sig.Signature}) + } result.SignCert = signatureCertInfo(sig.Cert) result.SealCert = signatureCertInfo(sig.Seal.Cert) result.SignCertRaw = sig.Cert @@ -255,9 +283,9 @@ func verifySESSignature(data, signedData []byte, options *signatureVerifyOptions return result, err } result.SignedChecked = true - result.SignedOK = sm2VerifySignature(signPub, nil, sig.ToSign, sig.Signature) + result.SignedOK = verifySM2Signature(signPub, nil, sig.ToSign, sig.Signature) result.SealChecked = true - result.SealOK = sm2VerifySignature(sealPub, nil, sig.Seal.SignData, sig.Seal.Signature) + result.SealOK = verifySM2Signature(sealPub, nil, sig.Seal.SignData, sig.Seal.Signature) result.CertChecked = true result.CertOK = sesCertInList(sig.Cert, sig.Seal.CertList) return result, nil @@ -297,20 +325,29 @@ func parseSESSeal(raw asn1.RawValue) (*sesSeal, error) { if err != nil { return nil, err } + var timestampRaw []byte + if len(items) == 5 { + if info.Version != 5 { + return nil, fmt.Errorf("unexpected timestamp in ses seal version %d", info.Version) + } + // V5仅保留解析能力, 不将未验证的制章时间戳当作可信时间 + timestampRaw = append([]byte(nil), items[4].FullBytes...) + } picType, picData, err := parseSESPicture(infoItems[3]) if err != nil { return nil, err } return &sesSeal{ - Raw: append([]byte(nil), raw.FullBytes...), - SignData: append([]byte(nil), items[0].FullBytes...), - Cert: cert, - SignAlg: alg, - Signature: signature, - PicType: picType, - PicData: picData, - CertList: certList, - Info: info, + TimestampRaw: timestampRaw, + Raw: append([]byte(nil), raw.FullBytes...), + SignData: append([]byte(nil), items[0].FullBytes...), + Cert: cert, + SignAlg: alg, + Signature: signature, + PicType: picType, + PicData: picData, + CertList: certList, + Info: info, }, nil } @@ -531,62 +568,25 @@ func parseSESPicture(raw asn1.RawValue) (string, []byte, error) { // parseSM2PublicKeyFromCert 从证书解析SM2公钥 // 入参: data DER编码证书 -// 返回: sm2PublicKey SM2公钥, error 错误信息 -func parseSM2PublicKeyFromCert(data []byte) (sm2PublicKey, error) { +// 返回: *ecdsa.PublicKey SM2公钥, error 错误信息 +func parseSM2PublicKeyFromCert(data []byte) (*ecdsa.PublicKey, error) { cert, err := parseSignatureCertificate(data) if err != nil { - return sm2PublicKey{}, err + return nil, err } - return parseSM2PublicKeyInfo(cert.PublicKey) -} - -// parseSM2PublicKeyInfo 解析SM2公钥信息 -// 入参: raw SubjectPublicKeyInfo原始值 -// 返回: sm2PublicKey SM2公钥, error 错误信息 -func parseSM2PublicKeyInfo(raw asn1.RawValue) (sm2PublicKey, error) { - var spki struct { - Algorithm asn1.RawValue - SubjectPublicKey asn1.BitString + pub, ok := cert.PublicKey.(*ecdsa.PublicKey) + if !ok || !sm2.IsSM2PublicKey(pub) { + return nil, fmt.Errorf("SM2 public key required") } - rest, err := asn1.Unmarshal(raw.FullBytes, &spki) - if err != nil { - return sm2PublicKey{}, err - } - if len(rest) != 0 { - return sm2PublicKey{}, fmt.Errorf("invalid subject public key info") - } - algItems, ok := asn1Children(spki.Algorithm.Bytes) - if !ok || len(algItems) < 2 { - return sm2PublicKey{}, fmt.Errorf("invalid public key algorithm") - } - alg, err := asn1OIDString(algItems[0]) - if err != nil { - return sm2PublicKey{}, err - } - curve, err := asn1OIDString(algItems[1]) - if err != nil { - return sm2PublicKey{}, err - } - if alg != signECPublicKey || curve != signCurveSM2P256 { - return sm2PublicKey{}, fmt.Errorf("unsupported public key algorithm") - } - key := spki.SubjectPublicKey.Bytes - if len(key) != signPublicKeySize || key[0] != 4 { - return sm2PublicKey{}, fmt.Errorf("invalid sm2 public key") - } - return sm2PublicKey{ - X: new(big.Int).SetBytes(key[1:33]), - Y: new(big.Int).SetBytes(key[33:65]), - }, nil + return pub, nil } // signSM3 计算SM3摘要 // 入参: data 原文数据 // 返回: []byte 摘要值 func signSM3(data []byte) []byte { - h := newSM3() - h.Write(data) - return h.Sum(nil) + digest := sm3.Sum(data) + return digest[:] } // isSM2SignatureMethod 判断是否为SM2签名算法 diff --git a/ofdgo_sign_sm2.go b/ofdgo_sign_sm2.go deleted file mode 100644 index d55ce83..0000000 --- a/ofdgo_sign_sm2.go +++ /dev/null @@ -1,326 +0,0 @@ -// Copyright 2025-2026 肖其顿 (XIAO QI DUN) -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package ofdgo - -import ( - "encoding/asn1" - "encoding/binary" - "math/big" -) - -const sm2DefaultUserID = "1234567812345678" - -var sm2P256 = newSM2P256() - -// sm2PublicKey SM2公钥 -type sm2PublicKey struct { - X *big.Int - Y *big.Int -} - -// sm2Curve SM2椭圆曲线 -type sm2Curve struct { - P *big.Int - N *big.Int - B *big.Int - Gx *big.Int - Gy *big.Int -} - -// sm2Point SM2雅可比坐标点 -type sm2Point struct { - X *big.Int - Y *big.Int - Z *big.Int -} - -// newSM2P256 创建SM2椭圆曲线 -// 返回: *sm2Curve SM2椭圆曲线 -func newSM2P256() *sm2Curve { - return &sm2Curve{ - P: sm2Big("FFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFF"), - N: sm2Big("FFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFF7203DF6B21C6052B53BBF40939D54123"), - B: sm2Big("28E9FA9E9D9F5E344D5A9E4BCF6509A7F39789F515AB8F92DDBCBD414D940E93"), - Gx: sm2Big("32C4AE2C1F1981195F9904466A39C9948FE30BBFF2660BE1715A4589334C74C7"), - Gy: sm2Big("BC3736A2F4F6779C59BDCEE36B692153D0A9877CC62A474002DF32E52139F0A0"), - } -} - -// sm2Big 解析SM2大整数常量 -// 入参: s 十六进制字符串 -// 返回: *big.Int 大整数 -func sm2Big(s string) *big.Int { - n, _ := new(big.Int).SetString(s, 16) - return n -} - -// sm2VerifySignature 验证SM2签名值 -// 入参: pub 公钥, userID 用户标识, msg 原文, sig 签名值 -// 返回: bool 是否验证通过 -func sm2VerifySignature(pub sm2PublicKey, userID, msg, sig []byte) bool { - r, s, ok := parseSM2Signature(sig) - if !ok { - return false - } - return sm2Verify(pub, userID, msg, r, s) -} - -// parseSM2Signature 解析SM2签名值 -// 入参: sig 签名值 -// 返回: *big.Int R值, *big.Int S值, bool 是否解析成功 -func parseSM2Signature(sig []byte) (*big.Int, *big.Int, bool) { - if len(sig) == 64 { - return new(big.Int).SetBytes(sig[:32]), new(big.Int).SetBytes(sig[32:]), true - } - var rs struct { - R *big.Int - S *big.Int - } - rest, err := asn1.Unmarshal(sig, &rs) - if err != nil || len(rest) != 0 || rs.R == nil || rs.S == nil { - return nil, nil, false - } - return rs.R, rs.S, true -} - -// sm2Verify 验证SM2签名 -// 入参: pub 公钥, userID 用户标识, msg 原文, r R值, s S值 -// 返回: bool 是否验证通过 -func sm2Verify(pub sm2PublicKey, userID, msg []byte, r, s *big.Int) bool { - n := sm2P256.N - if r.Sign() <= 0 || s.Sign() <= 0 || r.Cmp(n) >= 0 || s.Cmp(n) >= 0 { - return false - } - if pub.X == nil || pub.Y == nil || !sm2P256.isOnCurve(pub.X, pub.Y) { - return false - } - e := new(big.Int).SetBytes(sm2MessageDigest(pub, userID, msg)) - t := new(big.Int).Add(r, s) - t.Mod(t, n) - if t.Sign() == 0 { - return false - } - x, ok := sm2P256.combinedMult(pub.X, pub.Y, s, t) - if !ok { - return false - } - v := new(big.Int).Add(e, x) - v.Mod(v, n) - return v.Cmp(r) == 0 -} - -// sm2MessageDigest 计算SM2签名摘要 -// 入参: pub 公钥, userID 用户标识, msg 原文 -// 返回: []byte 摘要值 -func sm2MessageDigest(pub sm2PublicKey, userID, msg []byte) []byte { - h := newSM3() - h.Write(sm2ZA(pub, userID)) - h.Write(msg) - return h.Sum(nil) -} - -// sm2ZA 计算SM2用户标识杂凑值 -// 入参: pub 公钥, userID 用户标识 -// 返回: []byte ZA值 -func sm2ZA(pub sm2PublicKey, userID []byte) []byte { - if len(userID) == 0 { - userID = []byte(sm2DefaultUserID) - } - h := newSM3() - var entl [2]byte - binary.BigEndian.PutUint16(entl[:], uint16(len(userID)*8)) - h.Write(entl[:]) - h.Write(userID) - h.Write(sm2Fixed(sm2A())) - h.Write(sm2Fixed(sm2P256.B)) - h.Write(sm2Fixed(sm2P256.Gx)) - h.Write(sm2Fixed(sm2P256.Gy)) - h.Write(sm2Fixed(pub.X)) - h.Write(sm2Fixed(pub.Y)) - return h.Sum(nil) -} - -// sm2A 获取SM2曲线A参数 -// 返回: *big.Int 曲线A参数 -func sm2A() *big.Int { - return new(big.Int).Sub(sm2P256.P, big.NewInt(3)) -} - -// isOnCurve 判断点是否位于SM2曲线 -// 入参: x X坐标, y Y坐标 -// 返回: bool 是否位于曲线 -func (c *sm2Curve) isOnCurve(x, y *big.Int) bool { - if x.Sign() < 0 || y.Sign() < 0 || x.Cmp(c.P) >= 0 || y.Cmp(c.P) >= 0 { - return false - } - left := c.fieldSquare(y) - right := c.fieldAdd(c.fieldSub(c.fieldMul(c.fieldSquare(x), x), c.fieldScale(x, 3)), c.B) - return left.Cmp(right) == 0 -} - -// combinedMult 计算sG+tP -// 入参: x 公钥X坐标, y 公钥Y坐标, s 标量S, t 标量T -// 返回: *big.Int 结果X坐标, bool 是否计算成功 -func (c *sm2Curve) combinedMult(x, y, s, t *big.Int) (*big.Int, bool) { - base := c.scalarMult(c.Gx, c.Gy, s.Bytes()) - public := c.scalarMult(x, y, t.Bytes()) - result := c.add(base, public) - affineX, _, ok := c.affine(result) - return affineX, ok -} - -// scalarMult 计算椭圆曲线标量乘法 -// 入参: x 点X坐标, y 点Y坐标, scalar 标量 -// 返回: sm2Point 雅可比坐标点 -func (c *sm2Curve) scalarMult(x, y *big.Int, scalar []byte) sm2Point { - result := c.infinity() - point := sm2Point{X: new(big.Int).Set(x), Y: new(big.Int).Set(y), Z: big.NewInt(1)} - for _, value := range scalar { - for bit := 7; bit >= 0; bit-- { - result = c.double(result) - if value&(1< len(out) { - b = b[len(b)-len(out):] - } - copy(out[len(out)-len(b):], b) - return out -} diff --git a/ofdgo_sign_sm3.go b/ofdgo_sign_sm3.go deleted file mode 100644 index a27c820..0000000 --- a/ofdgo_sign_sm3.go +++ /dev/null @@ -1,211 +0,0 @@ -// Copyright 2025-2026 肖其顿 (XIAO QI DUN) -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package ofdgo - -import ( - "encoding/binary" - "hash" - "math/bits" -) - -const ( - sm3Size = 32 - sm3BlockSize = 64 -) - -var sm3Init = [8]uint32{ - 0x7380166f, - 0x4914b2b9, - 0x172442d7, - 0xda8a0600, - 0xa96f30bc, - 0x163138aa, - 0xe38dee4d, - 0xb0fb0e4e, -} - -// sm3Digest SM3杂凑值计算器 -type sm3Digest struct { - h [8]uint32 - x [sm3BlockSize]byte - nx int - len uint64 -} - -// newSM3 创建SM3杂凑值计算器 -// 返回: hash.Hash SM3杂凑值计算器 -func newSM3() hash.Hash { - d := new(sm3Digest) - d.Reset() - return d -} - -// Reset 重置SM3状态 -func (d *sm3Digest) Reset() { - d.h = sm3Init - d.nx = 0 - d.len = 0 -} - -// Size 获取SM3杂凑值长度 -// 返回: int 杂凑值长度 -func (d *sm3Digest) Size() int { - return sm3Size -} - -// BlockSize 获取SM3分组长度 -// 返回: int 分组长度 -func (d *sm3Digest) BlockSize() int { - return sm3BlockSize -} - -// Write 写入待计算数据 -// 入参: p 待计算数据 -// 返回: int 写入长度, error 错误信息 -func (d *sm3Digest) Write(p []byte) (int, error) { - nn := len(p) - d.len += uint64(nn) - if d.nx > 0 { - n := copy(d.x[d.nx:], p) - d.nx += n - if d.nx == sm3BlockSize { - sm3Block(d, d.x[:]) - d.nx = 0 - } - p = p[n:] - } - if len(p) >= sm3BlockSize { - n := len(p) &^ (sm3BlockSize - 1) - sm3Block(d, p[:n]) - p = p[n:] - } - if len(p) > 0 { - d.nx = copy(d.x[:], p) - } - return nn, nil -} - -// Sum 将SM3杂凑值追加到前缀数据后,不改变当前状态 -// 入参: in 前缀数据 -// 返回: []byte 前缀数据及杂凑值 -func (d *sm3Digest) Sum(in []byte) []byte { - dd := *d - hash := dd.checkSum() - return append(in, hash[:]...) -} - -// checkSum 计算SM3最终杂凑值 -// 返回: [sm3Size]byte 杂凑值 -func (d *sm3Digest) checkSum() [sm3Size]byte { - lenBits := d.len << 3 - var tmp [64]byte - tmp[0] = 0x80 - if d.nx < 56 { - d.Write(tmp[:56-d.nx]) - } else { - d.Write(tmp[:64+56-d.nx]) - } - binary.BigEndian.PutUint64(tmp[:8], lenBits) - d.Write(tmp[:8]) - var digest [sm3Size]byte - for i, v := range d.h { - binary.BigEndian.PutUint32(digest[i*4:], v) - } - return digest -} - -// sm3Block 处理SM3消息分组 -// 入参: d SM3杂凑值计算器, p 消息分组数据 -func sm3Block(d *sm3Digest, p []byte) { - var w [68]uint32 - var w1 [64]uint32 - for len(p) >= sm3BlockSize { - for i := 0; i < 16; i++ { - w[i] = binary.BigEndian.Uint32(p[i*4:]) - } - for i := 16; i < 68; i++ { - x := w[i-16] ^ w[i-9] ^ bits.RotateLeft32(w[i-3], 15) - w[i] = sm3P1(x) ^ bits.RotateLeft32(w[i-13], 7) ^ w[i-6] - } - for i := 0; i < 64; i++ { - w1[i] = w[i] ^ w[i+4] - } - a, b, c, e := d.h[0], d.h[1], d.h[2], d.h[4] - dd, f, g, hh := d.h[3], d.h[5], d.h[6], d.h[7] - for i := 0; i < 64; i++ { - t := uint32(0x7a879d8a) - if i < 16 { - t = 0x79cc4519 - } - ss1 := bits.RotateLeft32(bits.RotateLeft32(a, 12)+e+bits.RotateLeft32(t, i), 7) - ss2 := ss1 ^ bits.RotateLeft32(a, 12) - tt1 := sm3FF(i, a, b, c) + dd + ss2 + w1[i] - tt2 := sm3GG(i, e, f, g) + hh + ss1 + w[i] - dd = c - c = bits.RotateLeft32(b, 9) - b = a - a = tt1 - hh = g - g = bits.RotateLeft32(f, 19) - f = e - e = sm3P0(tt2) - } - d.h[0] ^= a - d.h[1] ^= b - d.h[2] ^= c - d.h[3] ^= dd - d.h[4] ^= e - d.h[5] ^= f - d.h[6] ^= g - d.h[7] ^= hh - p = p[sm3BlockSize:] - } -} - -// sm3P0 计算SM3置换函数P0 -// 入参: x 输入值 -// 返回: uint32 置换结果 -func sm3P0(x uint32) uint32 { - return x ^ bits.RotateLeft32(x, 9) ^ bits.RotateLeft32(x, 17) -} - -// sm3P1 计算SM3置换函数P1 -// 入参: x 输入值 -// 返回: uint32 置换结果 -func sm3P1(x uint32) uint32 { - return x ^ bits.RotateLeft32(x, 15) ^ bits.RotateLeft32(x, 23) -} - -// sm3FF 计算SM3布尔函数FF -// 入参: i 轮数, x 输入值, y 输入值, z 输入值 -// 返回: uint32 计算结果 -func sm3FF(i int, x, y, z uint32) uint32 { - if i < 16 { - return x ^ y ^ z - } - return (x & y) | (x & z) | (y & z) -} - -// sm3GG 计算SM3布尔函数GG -// 入参: i 轮数, x 输入值, y 输入值, z 输入值 -// 返回: uint32 计算结果 -func sm3GG(i int, x, y, z uint32) uint32 { - if i < 16 { - return x ^ y ^ z - } - return (x & y) | (^x & z) -} - -var _ hash.Hash = (*sm3Digest)(nil) diff --git a/ofdgo_sign_timestamp.go b/ofdgo_sign_timestamp.go new file mode 100644 index 0000000..bf723ab --- /dev/null +++ b/ofdgo_sign_timestamp.go @@ -0,0 +1,572 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "bytes" + "encoding/asn1" + "fmt" + "math/big" + "time" + + "github.com/emmansun/gmsm/smx509" +) + +const ( + signTimestampContent = "1.2.840.113549.1.9.16.1.4" + signTimestampAttr = "1.2.840.113549.1.9.16.2.14" + signContentCMS = "1.2.840.113549.1.7.2" + signAttrContentType = "1.2.840.113549.1.9.3" + signAttrESS = "1.2.840.113549.1.9.16.2.12" + signAttrESSV2 = "1.2.840.113549.1.9.16.2.47" +) + +// SignatureTimestampRequest 外部时间戳证据请求 +// Data为完整SignedValue文件字节, 回调返回的RFC3161令牌须绑定Data +type SignatureTimestampRequest struct { + DocIndex int + SignatureID string + Data []byte +} + +// SignatureTimestampOptions 离线RFC3161时间戳验证选项 +// Tokens及Fetch返回的令牌绑定完整SignedValue, 内嵌令牌绑定所属签名值 +// Fetch仅提供证据, 本库不主动联网, TrustCerts须由调用方显式提供 +type SignatureTimestampOptions struct { + Tokens [][]byte + TrustCerts [][]byte + Certificates [][]byte + Fetch func(SignatureTimestampRequest) ([][]byte, error) + Required bool + PolicyOID string + Nonce *big.Int + VerifyTime *time.Time +} + +// SignatureTimestampReport 时间戳验证报告 +// Time只表示令牌声明时间, 仅Valid为true时可作为可信时间使用 +type SignatureTimestampReport struct { + tsaName asn1.RawValue + Time time.Time + PolicyOID string + SerialNumber string + Nonce *big.Int + Signer SignatureCertInfo + BindingChecked bool + BindingOK bool + SignedValueChecked bool + SignedValueOK bool + CertTrustChecked bool + CertTrustOK bool + CertTimeChecked bool + CertTimeOK bool + Valid bool + Error string +} + +type signatureTimestampEvidence struct { + Token []byte + Data []byte +} + +// WithSignatureTimestamp 设置离线时间戳验证 +// 入参: options 时间戳选项 +// 返回: SignatureVerifyOption 验签选项 +func WithSignatureTimestamp(options SignatureTimestampOptions) SignatureVerifyOption { + options.Tokens = cloneSignatureEvidence(options.Tokens) + options.TrustCerts = appendSignatureCerts(nil, options.TrustCerts...) + options.Certificates = appendSignatureCerts(nil, options.Certificates...) + if options.Nonce != nil { + options.Nonce = new(big.Int).Set(options.Nonce) + } + if options.VerifyTime != nil { + t := *options.VerifyTime + options.VerifyTime = &t + } + return func(o *signatureVerifyOptions) { o.Timestamp = &options } +} + +// cloneSignatureEvidence 深拷贝离线证据 +// 入参: values 原证据列表 +// 返回: [][]byte 独立证据列表 +func cloneSignatureEvidence(values [][]byte) [][]byte { + out := make([][]byte, len(values)) + for i := range values { + out[i] = bytes.Clone(values[i]) + } + return out +} + +// VerifySignatureTimestamp 验证RFC3161令牌与原始字节的绑定 +// 入参: token DER编码TimeStampToken, data 被时间戳保护的原始字节, options 验证选项 +// 返回: SignatureTimestampReport 验证报告 +func VerifySignatureTimestamp(token, data []byte, options SignatureTimestampOptions) (report SignatureTimestampReport) { + err := verifySignatureTimestamp(token, data, options, &report) + if err != nil { + report.Error = err.Error() + } + report.Valid = err == nil && report.BindingChecked && report.BindingOK && report.SignedValueChecked && report.SignedValueOK && report.CertTrustChecked && report.CertTrustOK && report.CertTimeChecked && report.CertTimeOK + return report +} + +// verifySignatureTimestamp 检查令牌绑定、签名与TSA信任 +// 入参: token 令牌DER, data 原始字节, o 验证选项, report 结果 +// 返回: error 验证错误 +func verifySignatureTimestamp(token, data []byte, o SignatureTimestampOptions, report *SignatureTimestampReport) error { + oid, content, present, err := parseGBTContentInfoBytes(token) + if err != nil || !present || oid != signContentCMS { + return fmt.Errorf("invalid RFC3161 CMS token") + } + items, ok := asn1Children(content.Bytes) + if !ok || len(items) < 4 { + return fmt.Errorf("invalid timestamp signed data") + } + version, err := asn1Integer(items[0]) + if err != nil || version != 3 { + return fmt.Errorf("unsupported timestamp CMS version") + } + oid, value, present, err := parseGBTContentInfo(items[2]) + if err != nil || !present || oid != signTimestampContent { + return fmt.Errorf("invalid TSTInfo content type") + } + tst, err := asn1OctetString(value) + if err != nil { + return err + } + if err := verifyTimestampInfo(tst, data, o, report); err != nil { + return err + } + certs := appendSignatureCerts(nil, o.Certificates...) + idx := 3 + if items[idx].Class == asn1.ClassContextSpecific && items[idx].Tag == 0 { + parsed, err := parseGBTCertificates(items[idx]) + if err != nil { + return err + } + for _, cert := range parsed { + certs = append(certs, cert.Raw) + } + idx++ + } + if idx < len(items) && items[idx].Class == asn1.ClassContextSpecific && items[idx].Tag == 1 { + idx++ + } + if idx != len(items)-1 || items[idx].Tag != asn1.TagSet || items[idx].Class != asn1.ClassUniversal { + return fmt.Errorf("invalid timestamp signer set") + } + signers, err := parseGBTSignerInfos(items[idx]) + if err != nil || len(signers) != 1 { + return fmt.Errorf("timestamp requires one issuer-and-serial signer") + } + signer := signers[0] + if err := signatureSignerDigest(signer.SignatureAlg, signer.DigestAlg); err != nil { + return err + } + algorithms, ok := asn1Children(items[1].Bytes) + if !ok || items[1].Tag != asn1.TagSet || len(algorithms) != 1 { + return fmt.Errorf("invalid timestamp digest set") + } + digestAlg, err := parseGBTAlgorithm(algorithms[0]) + if err != nil || digestAlg != signer.DigestAlg { + return fmt.Errorf("timestamp digest set mismatch") + } + if err := signatureAlgorithmPolicy(&SignaturePolicy{RejectWeakAlgorithms: true}, signer.SignatureAlg, signer.DigestAlg); err != nil { + return err + } + attrs, err := signatureAuthenticatedAttributes(signer.AuthAttrs) + if err != nil { + return err + } + ct, err := asn1OIDString(attrs[signAttrContentType]) + if err != nil || ct != signTimestampContent { + return fmt.Errorf("timestamp content-type attribute mismatch") + } + digest, err := signatureDigest(signer.DigestAlg, tst) + if err != nil || !bytes.Equal(digest, signer.AttrDigest) { + return fmt.Errorf("timestamp content digest mismatch") + } + var cert []byte + for _, raw := range certs { + c, err := parseSignatureCertificate(raw) + if err == nil && bytes.Equal(c.RawIssuer, signer.Issuer) && c.SerialNumber.Cmp(signer.Serial) == 0 { + if cert != nil && !bytes.Equal(cert, raw) { + return fmt.Errorf("ambiguous TSA certificate") + } + cert = raw + } + } + if cert == nil { + return fmt.Errorf("TSA certificate not found") + } + if err := verifyTimestampESS(attrs, cert); err != nil { + return err + } + c, err := parseSignatureCertificate(cert) + if err != nil { + return err + } + if len(report.tsaName.FullBytes) != 0 && (report.tsaName.Class != asn1.ClassContextSpecific || report.tsaName.Tag != 4 || !bytes.Equal(report.tsaName.Bytes, c.RawSubject)) { + return fmt.Errorf("unsupported or mismatched TSTInfo TSA name") + } + critical := false + for _, extension := range c.Extensions { + if extension.Id.String() == "2.5.29.37" { + critical = extension.Critical + } + } + if !critical || len(c.UnknownExtKeyUsage) != 0 || len(c.ExtKeyUsage) != 1 || c.ExtKeyUsage[0] != smx509.ExtKeyUsageTimeStamping { + return fmt.Errorf("TSA certificate requires exclusive critical timestamping usage") + } + report.Signer = signatureCertInfo(cert) + report.SignedValueChecked = true + if isSM2SignatureMethod(signer.SignatureAlg) { + pub, err := parseSM2PublicKeyFromCert(cert) + if err != nil { + return err + } + report.SignedValueOK = verifySM2Signature(pub, nil, signer.AuthAttrs, signer.Signature) + } else { + report.SignedValueOK, err = verifyPublicKeySignature(signer.SignatureAlg, signer.DigestAlg, cert, signer.AuthAttrs, signer.Signature) + if err != nil { + return err + } + } + if !report.SignedValueOK { + return fmt.Errorf("invalid timestamp signature") + } + report.CertTimeChecked = true + report.CertTimeOK = timeInRange(report.Time, c.NotBefore, c.NotAfter) + if !report.CertTimeOK { + return fmt.Errorf("TSA certificate not valid at timestamp time") + } + trusts := appendSignatureCerts(nil, o.TrustCerts...) + if len(trusts) == 0 { + return fmt.Errorf("timestamp trust certificates not provided") + } + report.CertTrustChecked = true + report.CertTrustOK = verifySignatureCertificateChain(c, certs, trusts, report.Time, smx509.ExtKeyUsageTimeStamping) == nil + if !report.CertTrustOK { + return fmt.Errorf("untrusted TSA certificate") + } + return nil +} + +// verifyTimestampInfo 验证TSTInfo及可选nonce和策略 +// 入参: data TSTInfo字节, bound 被保护字节, o 验证选项, report 结果 +// 返回: error 验证错误 +func verifyTimestampInfo(data, bound []byte, o SignatureTimestampOptions, report *SignatureTimestampReport) error { + var raw asn1.RawValue + rest, err := asn1.Unmarshal(data, &raw) + if err != nil || len(rest) != 0 || raw.Tag != asn1.TagSequence { + return fmt.Errorf("invalid TSTInfo") + } + items, ok := asn1Children(raw.Bytes) + if !ok || len(items) < 5 { + return fmt.Errorf("invalid TSTInfo fields") + } + v, err := asn1Integer(items[0]) + if err != nil || v != 1 { + return fmt.Errorf("unsupported TSTInfo version") + } + report.PolicyOID, err = asn1OIDString(items[1]) + if err != nil { + return err + } + if o.PolicyOID != "" && o.PolicyOID != report.PolicyOID { + return fmt.Errorf("timestamp policy mismatch") + } + imprint, ok := asn1Children(items[2].Bytes) + if !ok || len(imprint) != 2 { + return fmt.Errorf("invalid timestamp imprint") + } + method, err := parseGBTAlgorithm(imprint[0]) + if err != nil { + return err + } + if err := signatureAlgorithmPolicy(&SignaturePolicy{RejectWeakAlgorithms: true}, "", method); err != nil { + return err + } + want, err := asn1OctetString(imprint[1]) + if err != nil { + return err + } + got, err := signatureDigest(method, bound) + if err != nil { + return err + } + report.BindingChecked, report.BindingOK = true, bytes.Equal(want, got) + if !report.BindingOK { + return fmt.Errorf("timestamp message imprint mismatch") + } + serial, err := asn1IntegerBig(items[3]) + if err != nil || serial.Sign() < 0 { + return fmt.Errorf("invalid timestamp serial") + } + report.SerialNumber = serial.String() + if items[4].Tag != asn1.TagGeneralizedTime { + return fmt.Errorf("invalid timestamp generalized time") + } + report.Time, err = asn1Time(items[4]) + if err != nil { + return err + } + now := time.Now() + if o.VerifyTime != nil { + now = *o.VerifyTime + } + if report.Time.After(now) { + return fmt.Errorf("timestamp is in the future") + } + previous := -1 + for _, item := range items[5:] { + order := -1 + switch { + case item.Class == asn1.ClassUniversal && item.Tag == asn1.TagSequence: + order = 0 + var accuracy struct { + Seconds int `asn1:"optional"` + Millis int `asn1:"optional,tag:0"` + Micros int `asn1:"optional,tag:1"` + } + rest, err := asn1.Unmarshal(item.FullBytes, &accuracy) + if err != nil || len(rest) != 0 || accuracy.Seconds < 0 || accuracy.Millis < 0 || accuracy.Millis > 999 || accuracy.Micros < 0 || accuracy.Micros > 999 { + return fmt.Errorf("invalid timestamp accuracy") + } + case item.Class == asn1.ClassUniversal && item.Tag == asn1.TagBoolean: + order = 1 + var ordering bool + if _, err := asn1.Unmarshal(item.FullBytes, &ordering); err != nil { + return err + } + case item.Class == asn1.ClassUniversal && item.Tag == asn1.TagInteger: + order = 2 + report.Nonce, err = asn1IntegerBig(item) + if err != nil || report.Nonce.Sign() < 0 { + return fmt.Errorf("invalid timestamp nonce") + } + case item.Class == asn1.ClassContextSpecific && item.Tag == 0: + order = 3 + report.tsaName, err = asn1Explicit(item) + if err != nil { + return err + } + case item.Class == asn1.ClassContextSpecific && item.Tag == 1: + return fmt.Errorf("timestamp extensions not supported") + default: + return fmt.Errorf("invalid timestamp optional field") + } + if order <= previous { + return fmt.Errorf("duplicate or unordered timestamp field") + } + previous = order + } + if o.Nonce != nil && (report.Nonce == nil || report.Nonce.Cmp(o.Nonce) != 0) { + return fmt.Errorf("timestamp nonce mismatch") + } + return nil +} + +// signatureAuthenticatedAttributes 拒绝重复属性和非DER排序 +// 入参: data 签名属性DER集合 +// 返回: map[string]asn1.RawValue 属性映射, error 解析错误 +func signatureAuthenticatedAttributes(data []byte) (map[string]asn1.RawValue, error) { + var raw asn1.RawValue + rest, err := asn1.Unmarshal(data, &raw) + if err != nil || len(rest) != 0 || raw.Class != asn1.ClassUniversal || raw.Tag != asn1.TagSet { + return nil, fmt.Errorf("invalid signed attributes") + } + attrs, ok := asn1Children(raw.Bytes) + if !ok { + return nil, fmt.Errorf("invalid signed attributes") + } + out := make(map[string]asn1.RawValue) + var previous []byte + for _, attr := range attrs { + if bytes.Compare(previous, attr.FullBytes) > 0 { + return nil, fmt.Errorf("non-DER signed attributes") + } + previous = attr.FullBytes + items, ok := asn1Children(attr.Bytes) + if !ok || len(items) != 2 || items[1].Tag != asn1.TagSet { + return nil, fmt.Errorf("invalid signed attribute") + } + oid, err := asn1OIDString(items[0]) + if err != nil { + return nil, err + } + if _, exists := out[oid]; exists { + return nil, fmt.Errorf("duplicate signed attribute: %s", oid) + } + values, ok := asn1Children(items[1].Bytes) + if !ok || len(values) != 1 { + return nil, fmt.Errorf("signed attribute must have one value") + } + out[oid] = values[0] + } + return out, nil +} + +// verifyTimestampESS 验证ESS证书摘要及颁发者序列号绑定 +// 入参: attrs 签名属性, cert TSA证书DER +// 返回: error 绑定错误 +func verifyTimestampESS(attrs map[string]asn1.RawValue, cert []byte) error { + found := false + for _, oid := range []string{signAttrESS, signAttrESSV2} { + value, exists := attrs[oid] + if !exists { + continue + } + found = true + outer, ok := asn1Children(value.Bytes) + if !ok || len(outer) < 1 || len(outer) > 2 { + return fmt.Errorf("invalid ESS signing certificate") + } + certs, ok := asn1Children(outer[0].Bytes) + if !ok || len(certs) == 0 { + return fmt.Errorf("empty ESS certificate list") + } + fields, ok := asn1Children(certs[0].Bytes) + if !ok || len(fields) == 0 { + return fmt.Errorf("invalid ESS certificate identifier") + } + method := "SHA1" + idx := 0 + if oid == signAttrESSV2 { + method = "SHA256" + if fields[0].Tag == asn1.TagSequence { + var err error + method, err = parseGBTAlgorithm(fields[0]) + if err != nil { + return err + } + idx++ + } + } + if len(fields) < idx+1 || len(fields) > idx+2 { + return fmt.Errorf("invalid ESS certificate fields") + } + want, err := asn1OctetString(fields[idx]) + if err != nil { + return err + } + got, err := signatureDigest(method, cert) + if err != nil || !bytes.Equal(want, got) { + return fmt.Errorf("ESS certificate hash mismatch") + } + if len(fields) == idx+2 { + issuer, ok := asn1Children(fields[idx+1].Bytes) + if !ok || len(issuer) != 2 { + return fmt.Errorf("invalid ESS issuer serial") + } + serial, err := asn1IntegerBig(issuer[1]) + if err != nil { + return err + } + c, err := parseSignatureCertificate(cert) + if err != nil { + return err + } + names, ok := asn1Children(issuer[0].Bytes) + matched := false + for _, name := range names { + matched = matched || name.Class == asn1.ClassContextSpecific && name.Tag == 4 && bytes.Equal(name.Bytes, c.RawIssuer) + } + if !ok || !matched || serial.Cmp(c.SerialNumber) != 0 { + return fmt.Errorf("ESS issuer serial mismatch") + } + } + } + if !found { + return fmt.Errorf("timestamp ESS certificate binding missing") + } + return nil +} + +// signatureTimestampAttributes 提取签名值中的RFC3161未认证属性 +// 入参: raw 未认证属性ASN.1值 +// 返回: [][]byte 时间戳令牌, error 解析错误 +func signatureTimestampAttributes(raw asn1.RawValue) ([][]byte, error) { + if len(raw.FullBytes) == 0 { + return nil, nil + } + attrs, ok := asn1Children(raw.Bytes) + if !ok { + return nil, fmt.Errorf("invalid timestamp attributes") + } + var tokens [][]byte + for _, attr := range attrs { + items, ok := asn1Children(attr.Bytes) + if !ok || len(items) != 2 { + return nil, fmt.Errorf("invalid unsigned attribute") + } + oid, err := asn1OIDString(items[0]) + if err != nil { + return nil, err + } + if oid != signTimestampAttr { + continue + } + if len(tokens) != 0 { + return nil, fmt.Errorf("duplicate timestamp attribute") + } + values, ok := asn1Children(items[1].Bytes) + if !ok || items[1].Tag != asn1.TagSet || len(values) == 0 { + return nil, fmt.Errorf("empty timestamp attribute") + } + for _, value := range values { + tokens = append(tokens, bytes.Clone(value.FullBytes)) + } + } + return tokens, nil +} + +// applySignatureEvidence 验证内嵌及外部时间戳并应用吊销策略 +// 入参: o 验签选项, signedValue 签名文件字节, embedded 内嵌令牌, certs 签署证书, extra 辅助证书 +func (report *SignatureVerifyReport) applySignatureEvidence(o *signatureVerifyOptions, signedValue []byte, embedded []signatureTimestampEvidence, certs, extra [][]byte) { + var options SignatureTimestampOptions + if o.Timestamp != nil { + options = *o.Timestamp + } + if o.Policy != nil { + options.Required = options.Required || o.Policy.RequireTimestamp + } + evidence := append([]signatureTimestampEvidence(nil), embedded...) + for _, token := range options.Tokens { + evidence = append(evidence, signatureTimestampEvidence{Token: token, Data: signedValue}) + } + if options.Fetch != nil { + tokens, err := options.Fetch(SignatureTimestampRequest{DocIndex: report.DocIndex, SignatureID: report.ID, Data: bytes.Clone(signedValue)}) + if err != nil { + report.Timestamps = append(report.Timestamps, SignatureTimestampReport{Error: err.Error()}) + } + for _, token := range tokens { + evidence = append(evidence, signatureTimestampEvidence{Token: token, Data: signedValue}) + } + } + for _, item := range evidence { + report.Timestamps = append(report.Timestamps, VerifySignatureTimestamp(item.Token, item.Data, options)) + } + if len(report.Timestamps) != 0 { + report.TimestampChecked, report.TimestampOK = true, true + for _, result := range report.Timestamps { + report.TimestampOK = report.TimestampOK && result.Valid + } + } + if options.Required && !report.TimestampChecked { + report.PolicyChecked, report.PolicyOK = true, false + report.PolicyError = "required timestamp evidence missing" + } + report.applySignatureRevocation(o, certs, extra) +} diff --git a/ofdgo_sign_verify.go b/ofdgo_sign_verify.go index fc11b06..747860d 100644 --- a/ofdgo_sign_verify.go +++ b/ofdgo_sign_verify.go @@ -24,7 +24,6 @@ import ( "crypto/sha256" "crypto/sha512" "crypto/subtle" - "crypto/x509" "encoding/asn1" "encoding/base64" "encoding/pem" @@ -35,13 +34,32 @@ import ( "path" "strings" "time" + + "github.com/emmansun/gmsm/sm2" + "github.com/emmansun/gmsm/smx509" ) // SignatureVerifyReport 签名验证报告 -// Valid表示签名完整性、签名时间语义及调用方指定的证书策略均通过 +// Valid表示签名完整性、签名时间语义及调用方指定的全部策略均通过 // Checked表示对应检查已得出结论, 未检查时不能将OK的零值视为失败 // SealCertTimeOK仅提供制章证书在签名时间的状态信息, 不参与Valid判断 type SignatureVerifyReport struct { + DocIndex int + DocRoot string + PolicyChecked bool + PolicyOK bool + PolicyError string + CoverageChecked bool + CoverageOK bool + CoverageError string + CoveredFiles []string + UncoveredFiles []string + TimestampChecked bool + TimestampOK bool + Timestamps []SignatureTimestampReport + RevocationChecked bool + RevocationOK bool + Revocations []SignatureRevocationReport ID string BaseLoc string Type SignType @@ -80,6 +98,7 @@ type SignatureVerifyReport struct { CertTimeOK bool CertTrustChecked bool CertTrustOK bool + CertTrustError string Valid bool Error string } @@ -87,7 +106,10 @@ type SignatureVerifyReport struct { // IntegrityValid 判断签名完整性是否有效 // 返回: bool 是否有效 func (report SignatureVerifyReport) IntegrityValid() bool { - return report.Error == "" && report.DigestOK && report.DataHashOK && report.SignedValueOK && report.SealOK && report.SealMatchOK && report.CertOK + sealOK := report.Type == SignTypeSign || report.SealChecked && report.SealOK + return report.Error == "" && report.DigestOK && referencesOK(report.References) && + report.DataHashChecked && report.DataHashOK && report.SignedValueChecked && report.SignedValueOK && + sealOK && report.SealMatchOK && report.CertChecked && report.CertOK } // TrustedValid 判断签名是否可信有效 @@ -151,6 +173,11 @@ type signatureVerifyOptions struct { SignCerts [][]byte TrustCerts [][]byte VerifyTime *time.Time + Policy *SignaturePolicy + Timestamp *SignatureTimestampOptions + Revocation *SignatureRevocationOptions + DocIndex int + DocRoot string } var signatureMethodReplacer = strings.NewReplacer("-", "", "_", "", " ", "") @@ -177,6 +204,7 @@ func WithSignatureCerts(certs ...[]byte) SignatureVerifyOption { } // WithSignatureTrustCert 添加签名信任证书 +// 指定信任后同时验证证书链有效期,默认使用当前时间 // 入参: cert DER或PEM编码证书 // 返回: SignatureVerifyOption 签名验证选项 func WithSignatureTrustCert(cert []byte) SignatureVerifyOption { @@ -186,6 +214,7 @@ func WithSignatureTrustCert(cert []byte) SignatureVerifyOption { } // WithSignatureTrustCerts 添加多张签名信任证书 +// 指定信任后同时验证证书链有效期,默认使用当前时间 // 入参: certs DER或PEM编码证书列表 // 返回: SignatureVerifyOption 签名验证选项 func WithSignatureTrustCerts(certs ...[]byte) SignatureVerifyOption { @@ -246,19 +275,56 @@ func VerifySignaturesReader(r io.ReaderAt, size int64, opts ...SignatureVerifyOp // 入参: opts 签名验证选项 // 返回: []SignatureVerifyReport 签名验证报告, error 错误信息 func (r *Reader) VerifySignatures(opts ...SignatureVerifyOption) ([]SignatureVerifyReport, error) { + if r.OFD == nil || len(r.OFD.DocBody) == 0 { + return nil, fmt.Errorf("no docbody found") + } + if _, err := r.Doc(); err != nil { + return nil, err + } + var reports []SignatureVerifyReport + for i := range r.OFD.DocBody { + items, err := r.VerifyDocumentSignatures(i, opts...) + reports = append(reports, items...) + if err != nil { + return reports, fmt.Errorf("document %d: %w", i, err) + } + } + return reports, nil +} + +// VerifyDocumentSignatures 验证指定DocBody的签名且不切换阅读器当前文档 +// 入参: index 从0开始的文档索引, opts 签名验证选项 +// 返回: []SignatureVerifyReport 签名验证报告, error 错误信息 +func (r *Reader) VerifyDocumentSignatures(index int, opts ...SignatureVerifyOption) ([]SignatureVerifyReport, error) { + if r.OFD == nil || index < 0 || index >= len(r.OFD.DocBody) { + return nil, fmt.Errorf("document index out of range: %d", index) + } options := signatureVerifyOptions{} for _, opt := range opts { - opt(&options) + if opt != nil { + opt(&options) + } } - doc, err := r.Doc() + body := r.OFD.DocBody[index] + options.DocIndex, options.DocRoot = index, r.signatureCoveragePath(body.DocRoot) + data, err := r.readFile(body.DocRoot) if err != nil { return nil, err } + var doc Document + if err := xml.Unmarshal(data, &doc); err != nil { + return nil, err + } + if doc.Signatures == "" { + doc.Signatures = body.Signatures + } if doc.Signatures == "" { return nil, nil } - sigListPath := r.ResPath(doc.Signatures) - data, err := r.readFileExact(sigListPath) + view := *r + view.doc, view.RootDir = &doc, path.Dir(body.DocRoot) + sigListPath := view.ResPath(doc.Signatures) + data, err = view.readFile(sigListPath) if err != nil { return nil, err } @@ -268,7 +334,7 @@ func (r *Reader) VerifySignatures(opts ...SignatureVerifyOption) ([]SignatureVer } reports := make([]SignatureVerifyReport, 0, len(signatures.List)) for _, sigRef := range signatures.List { - reports = append(reports, r.verifySignature(sigListPath, sigRef, &options)) + reports = append(reports, view.verifySignature(sigListPath, sigRef, &options)) } return reports, nil } @@ -279,6 +345,8 @@ func (r *Reader) VerifySignatures(opts ...SignatureVerifyOption) ([]SignatureVer func (r *Reader) verifySignature(sigListPath string, sigRef Signature, options *signatureVerifyOptions) SignatureVerifyReport { sigPath := signatureRefPath(sigListPath, sigRef.BaseLoc) report := SignatureVerifyReport{ + DocIndex: options.DocIndex, + DocRoot: options.DocRoot, ID: sigRef.ID, BaseLoc: sigRef.BaseLoc, Type: sigRef.Type, @@ -287,7 +355,7 @@ func (r *Reader) verifySignature(sigListPath string, sigRef Signature, options * if report.Type == "" { report.Type = SignTypeSeal } - sigData, err := r.readFileExact(sigPath) + sigData, err := r.readFile(sigPath) if err != nil { report.Error = err.Error() return report @@ -311,8 +379,10 @@ func (r *Reader) verifySignature(sigListPath string, sigRef Signature, options * report.StampPositionError = err.Error() } report.DigestOK = referencesOK(report.References) + r.applySignatureCoverage(&report, sigListPath, options) + report.applySignaturePolicy(options, r.OFD.Version, 0, report.SignatureMethod, report.DigestMethod) signedValuePath := signatureRefPath(sigPath, sigFile.SignedValue) - signedValue, err := r.readFileExact(signedValuePath) + signedValue, err := r.readFile(signedValuePath) if err != nil { report.Error = err.Error() return report @@ -331,6 +401,7 @@ func (r *Reader) verifySignature(sigListPath string, sigRef Signature, options * report.Signer = result.CertInfo.CommonName } if err != nil { + report.applySignaturePolicyError(err) report.Error = err.Error() return report } @@ -338,6 +409,7 @@ func (r *Reader) verifySignature(sigListPath string, sigRef Signature, options * report.SignatureTime = parseSignatureDateTime(report.SignatureDateTime) report.applySignatureTimePolicy() report.applySignatureCertificatePolicy(options, result.SignerCerts, result.Certs) + report.applySignatureEvidence(options, signedValue, result.Timestamps, result.SignerCerts, result.Certs) report.Valid = report.IntegrityValid() && report.certificatePolicyOK() return report case SignTypeSeal: @@ -363,14 +435,21 @@ func (r *Reader) verifySignature(sigListPath string, sigRef Signature, options * report.Signer = sesResult.SignCert.CommonName } if err != nil { + report.applySignaturePolicyError(err) report.Error = err.Error() return report } report.applySignatureTimePolicy() + if report.SignatureMethod != "" && !signatureAlgorithmEquivalent(report.SignatureMethod, sesResult.SignatureMethod, "SM3") { + report.Error = "signature method does not match SES" + return report + } report.applySignatureCertificatePolicy(options, [][]byte{sesResult.SignCertRaw, sesResult.SealCertRaw}, sesResult.Certs) + report.applySignaturePolicy(options, r.OFD.Version, sesResult.SealInfo.Version, sesResult.SignatureMethod, "SM3") + report.applySignatureEvidence(options, signedValue, sesResult.Timestamps, [][]byte{sesResult.SignCertRaw, sesResult.SealCertRaw}, sesResult.Certs) if sigFile.SignedInfo.Seal.BaseLoc != "" { sealPath := signatureRefPath(sigPath, sigFile.SignedInfo.Seal.BaseLoc) - sealData, err := r.readFileExact(sealPath) + sealData, err := r.readFile(sealPath) if err != nil { report.Error = err.Error() return report @@ -407,7 +486,7 @@ func (r *Reader) verifySignatureReference(sigPath, method string, ref SignatureR result.Error = err.Error() return result } - data, err := r.readFileExact(refPath) + data, err := r.readFile(refPath) if err != nil { result.Error = err.Error() return result @@ -435,20 +514,6 @@ func parseSignatureFile(data []byte) (*SignatureFile, error) { return &sigFile, nil } -// readFileExact 读取OFD包内文件 -// 入参: name 包内文件路径 -// 返回: []byte 文件数据, error 错误信息 -func (r *Reader) readFileExact(name string) ([]byte, error) { - name = cleanPackagePath(name) - if data, ok := r.files[name]; ok { - return bytes.Clone(data), nil - } - if f, ok := r.fileIndex[name]; ok { - return readZipFile(f) - } - return nil, fmt.Errorf("file not found: %s", name) -} - // signatureDigest 计算签名摘要 // 入参: method 摘要算法, data 原文数据 // 返回: []byte 摘要值, error 错误信息 @@ -612,7 +677,7 @@ func verifyPublicKeySignature(method, digestMethod string, cert, signedData, sig if len(digest) == 0 { return false, fmt.Errorf("unsupported digest method") } - x509Cert, err := x509.ParseCertificate(cert) + x509Cert, err := parseSignatureCertificate(cert) if err != nil { return false, err } @@ -632,6 +697,23 @@ func verifyPublicKeySignature(method, digestMethod string, cert, signedData, sig } } +// verifySM2Signature 验证DER或定长拼接编码的SM2签名值 +// 入参: pub 公钥, userID 用户标识, msg 原文, sig 签名值 +// 返回: bool 是否验证通过 +func verifySM2Signature(pub *ecdsa.PublicKey, userID, msg, sig []byte) bool { + if sm2.VerifyASN1WithSM2(pub, userID, msg, sig) { + return true + } + if len(sig) != 64 { + return false + } + digest, err := sm2.CalculateSM2Hash(pub, msg, userID) + if err != nil { + return false + } + return sm2.Verify(pub, digest, new(big.Int).SetBytes(sig[:32]), new(big.Int).SetBytes(sig[32:])) +} + // verifyECDSASignature 验证ECDSA签名 // 入参: pub 公钥, digest 摘要, sig 签名值 // 返回: bool 是否验证通过 @@ -668,7 +750,7 @@ func referencesOK(refs []SignatureReferenceVerify) bool { return false } for _, ref := range refs { - if !ref.OK { + if !ref.Checked || !ref.OK || ref.Error != "" { return false } } @@ -679,9 +761,14 @@ func referencesOK(refs []SignatureReferenceVerify) bool { // 入参: options 验证选项, certs 待验证证书, extraCerts 证书池 func (report *SignatureVerifyReport) applySignatureCertificatePolicy(options *signatureVerifyOptions, certs [][]byte, extraCerts [][]byte) { certs = compactSignatureCerts(certs) - if options.VerifyTime != nil { + verifyTime := options.VerifyTime + if verifyTime == nil && len(options.TrustCerts) != 0 { + now := time.Now() + verifyTime = &now + } + if verifyTime != nil { report.CertTimeChecked = true - report.CertTimeOK = signatureCertsValidAt(certs, *options.VerifyTime) + report.CertTimeOK = signatureCertsValidAt(certs, *verifyTime) } if len(options.TrustCerts) != 0 { report.CertTrustChecked = true @@ -691,13 +778,15 @@ func (report *SignatureVerifyReport) applySignatureCertificatePolicy(options *si pool = append(pool, extraCerts...) pool = compactSignatureCerts(pool) for _, cert := range certs { - if !signatureCertTrustedBy(cert, pool, options.TrustCerts, options.VerifyTime) { + if err := verifySignatureCertificateTrust(cert, pool, options.TrustCerts, *verifyTime); err != nil { report.CertTrustOK = false + report.CertTrustError = err.Error() break } } if len(certs) == 0 { report.CertTrustOK = false + report.CertTrustError = "signature certificate missing" } } } @@ -728,6 +817,10 @@ func timeInRange(t, start, end time.Time) bool { // certificatePolicyOK 判断证书策略是否通过 // 返回: bool 是否通过 func (report SignatureVerifyReport) certificatePolicyOK() bool { + if report.PolicyChecked && !report.PolicyOK || report.CoverageChecked && !report.CoverageOK || + report.TimestampChecked && !report.TimestampOK || report.RevocationChecked && !report.RevocationOK { + return false + } if report.SignatureTimeChecked && !report.SignatureTimeOK { return false } @@ -759,112 +852,51 @@ func signatureCertsValidAt(certs [][]byte, t time.Time) bool { return true } -// signatureCertMaxChecks 最大证书签名验证次数 -const signatureCertMaxChecks = 100 - -// signatureCertPathState 证书路径状态 -type signatureCertPathState struct { - Visited map[string]bool - SignatureChecks int -} - -// signatureCertTrustedBy 判断证书是否可链到信任证书 -// 入参: cert 证书, pool 证书池, trusts 信任证书, verifyTime 中间证书验证时间 -// 返回: bool 是否受信任 -func signatureCertTrustedBy(cert []byte, pool, trusts [][]byte, verifyTime *time.Time) bool { - state := signatureCertPathState{Visited: make(map[string]bool)} - return signatureCertPathTrustedBy(cert, pool, trusts, verifyTime, &state, 0, true) -} - -// signatureCertPathTrustedBy 验证证书路径 -// 入参: cert 证书, pool 证书池, trusts 信任证书, verifyTime 中间证书验证时间, state 路径状态, caBelow 下级非自颁发中间CA数量, target 是否目标证书 -// 返回: bool 是否受信任 -func signatureCertPathTrustedBy(cert []byte, pool, trusts [][]byte, verifyTime *time.Time, state *signatureCertPathState, caBelow int, target bool) bool { - if len(cert) == 0 { - return false - } - trusted := false - for _, trust := range trusts { - if bytes.Equal(cert, trust) { - trusted = true - break - } - } - if trusted && !target { - return true - } - key := string(cert) - if state.Visited[key] { - return false - } - state.Visited[key] = true - defer delete(state.Visited, key) +// verifySignatureCertificateTrust 按指定时间验证签署证书到显式信任根的证书链 +// 入参: cert 证书, pool 证书池, trusts 信任证书, at 验证时间 +// 返回: error 验证错误 +func verifySignatureCertificateTrust(cert []byte, pool, trusts [][]byte, at time.Time) error { c, err := parseSignatureCertificate(cert) if err != nil { - return false + return err } - if c.UnhandledCritical { - return false + if c.KeyUsage != 0 && c.KeyUsage&(smx509.KeyUsageDigitalSignature|smx509.KeyUsageContentCommitment) == 0 { + return fmt.Errorf("certificate does not authorize digital signatures") } - if !target && verifyTime != nil && (verifyTime.Before(c.NotBefore) || verifyTime.After(c.NotAfter)) { - return false - } - if target { - if c.KeyUsage != 0 && c.KeyUsage&(x509.KeyUsageDigitalSignature|x509.KeyUsageContentCommitment) == 0 { - return false - } - } else { - if !c.IsCA { - return false - } - if c.KeyUsage != 0 && c.KeyUsage&x509.KeyUsageCertSign == 0 { - return false - } - if c.MaxPathLen != nil && c.MaxPathLen.Cmp(big.NewInt(int64(caBelow))) < 0 { - return false + return verifySignatureCertificateChain(c, pool, trusts, at, smx509.ExtKeyUsageAny) +} + +// verifySignatureCertificateChain 使用统一X.509实现验证证书链 +// 入参: cert 目标证书, pool 中间证书, trusts 信任根, at 验证时间, usage 扩展用途 +// 返回: error 验证错误 +func verifySignatureCertificateChain(cert *smx509.Certificate, pool, trusts [][]byte, at time.Time, usage smx509.ExtKeyUsage) error { + roots, intermediates := smx509.NewCertPool(), smx509.NewCertPool() + for _, group := range []struct { + data [][]byte + pool *smx509.CertPool + }{{trusts, roots}, {pool, intermediates}} { + for _, data := range group.data { + ca, err := parseSignatureCertificate(data) + if err != nil { + return err + } + group.pool.AddCert(ca) } } - if trusted { - return true - } - nextCABelow := caBelow - if !target && !bytes.Equal(c.Issuer, c.Subject) { - nextCABelow++ - } - for _, issuerCert := range pool { - if state.SignatureChecks >= signatureCertMaxChecks { - return false - } - if bytes.Equal(cert, issuerCert) { - continue - } - issuer, err := parseSignatureCertificate(issuerCert) - if err != nil || !bytes.Equal(c.Issuer, issuer.Subject) { - continue - } - state.SignatureChecks++ - if ok, err := verifyCertificateSignature(c, issuerCert); err != nil || !ok { - continue - } - if signatureCertPathTrustedBy(issuerCert, pool, trusts, verifyTime, state, nextCABelow, false) { - return true - } - } - return false + _, err := cert.Verify(smx509.VerifyOptions{Roots: roots, Intermediates: intermediates, CurrentTime: at, KeyUsages: []smx509.ExtKeyUsage{usage}}) + return err } // verifyCertificateSignature 验证证书签名 // 入参: cert 证书信息, issuerCert 颁发者证书 // 返回: bool 是否验证通过, error 错误信息 -func verifyCertificateSignature(cert signatureCertificate, issuerCert []byte) (bool, error) { - if isSM2SignatureMethod(cert.SignatureAlg) { - pub, err := parseSM2PublicKeyFromCert(issuerCert) - if err != nil { - return false, err - } - return sm2VerifySignature(pub, nil, cert.TBS, cert.Signature), nil +func verifyCertificateSignature(cert *smx509.Certificate, issuerCert []byte) (bool, error) { + issuer, err := parseSignatureCertificate(issuerCert) + if err != nil { + return false, err } - return verifyPublicKeySignature(cert.SignatureAlg, "", issuerCert, cert.TBS, cert.Signature) + err = issuer.CheckSignature(cert.SignatureAlgorithm, cert.RawTBSCertificate, cert.Signature) + return err == nil, err } // compactSignatureCerts 清理证书列表 @@ -895,21 +927,16 @@ func signatureCertInfo(data []byte) SignatureCertInfo { if err != nil { return SignatureCertInfo{} } - subject := certificateNameValues(cert.SubjectValue) - issuer := certificateNameValues(cert.IssuerValue) - info := SignatureCertInfo{ - Raw: append([]byte(nil), data...), - Subject: certificateNameString(subject), - CommonName: certificateNameFirst(subject, "2.5.4.3"), - Organization: certificateNameFirst(subject, "2.5.4.10"), - Issuer: certificateNameString(issuer), + return SignatureCertInfo{ + Raw: bytes.Clone(cert.Raw), + Subject: cert.Subject.String(), + CommonName: cert.Subject.CommonName, + Organization: strings.Join(cert.Subject.Organization, ", "), + Issuer: cert.Issuer.String(), NotBefore: cert.NotBefore, NotAfter: cert.NotAfter, + SerialNumber: cert.SerialNumber.String(), } - if cert.Serial != nil { - info.SerialNumber = cert.Serial.String() - } - return info } // parseSignatureDateTime 解析带时区的签名时间 @@ -929,227 +956,17 @@ func parseSignatureDateTime(value string) time.Time { return time.Time{} } -const ( - signatureExtensionKeyUsage = "2.5.29.15" - signatureExtensionBasicConstraints = "2.5.29.19" -) - -// signatureCertificateExtensions 签名证书扩展 -type signatureCertificateExtensions struct { - IsCA bool - MaxPathLen *big.Int - KeyUsage x509.KeyUsage - UnhandledCritical bool -} - -// signatureCertificate 签名证书结构 -type signatureCertificate struct { - Raw []byte - TBS []byte - Issuer []byte - IssuerValue asn1.RawValue - Subject []byte - SubjectValue asn1.RawValue - PublicKey asn1.RawValue - Serial *big.Int - NotBefore time.Time - NotAfter time.Time - SignatureAlg string - Signature []byte - signatureCertificateExtensions -} - -// parseSignatureCertificate 解析签名证书 -// 入参: data DER编码证书 -// 返回: signatureCertificate 签名证书结构, error 错误信息 -func parseSignatureCertificate(data []byte) (signatureCertificate, error) { - var cert struct { - TBSCertificate asn1.RawValue - SignatureAlgorithm asn1.RawValue - SignatureValue asn1.BitString - } - rest, err := asn1.Unmarshal(data, &cert) - if err != nil || len(rest) != 0 { - return signatureCertificate{}, fmt.Errorf("invalid certificate") - } - items, ok := asn1Children(cert.TBSCertificate.Bytes) - if !ok { - return signatureCertificate{}, fmt.Errorf("invalid tbs certificate") - } - idx := 0 - if len(items) > 0 && items[0].Class == asn1.ClassContextSpecific && items[0].Tag == 0 { - idx++ - } - if len(items) <= idx+5 { - return signatureCertificate{}, fmt.Errorf("invalid certificate") - } - if !bytes.Equal(items[idx+1].FullBytes, cert.SignatureAlgorithm.FullBytes) { - return signatureCertificate{}, fmt.Errorf("certificate signature algorithm mismatch") - } - serial, err := asn1IntegerBig(items[idx]) - if err != nil { - return signatureCertificate{}, err - } - validity, err := parseCertificateValidity(items[idx+3]) - if err != nil { - return signatureCertificate{}, err - } - extensions, err := parseSignatureCertificateExtensions(items[idx+6:]) - if err != nil { - return signatureCertificate{}, err - } - alg, err := parseGBTAlgorithm(cert.SignatureAlgorithm) - if err != nil { - return signatureCertificate{}, err - } - if cert.SignatureValue.BitLength%8 != 0 { - return signatureCertificate{}, fmt.Errorf("invalid certificate signature") - } - return signatureCertificate{ - Raw: append([]byte(nil), data...), - TBS: append([]byte(nil), cert.TBSCertificate.FullBytes...), - Issuer: append([]byte(nil), items[idx+2].FullBytes...), - IssuerValue: items[idx+2], - Subject: append([]byte(nil), items[idx+4].FullBytes...), - SubjectValue: items[idx+4], - PublicKey: items[idx+5], - Serial: serial, - NotBefore: validity[0], - NotAfter: validity[1], - SignatureAlg: alg, - Signature: append([]byte(nil), cert.SignatureValue.Bytes...), - signatureCertificateExtensions: extensions, - }, nil -} - -// parseSignatureCertificateExtensions 解析签名证书扩展 -// 入参: items TBS证书剩余字段 -// 返回: signatureCertificateExtensions 签名证书扩展, error 错误信息 -func parseSignatureCertificateExtensions(items []asn1.RawValue) (signatureCertificateExtensions, error) { - var out signatureCertificateExtensions - var extensions []struct { - ID asn1.ObjectIdentifier - Critical bool `asn1:"optional"` - Value []byte - } - found := false - for _, item := range items { - if item.Class != asn1.ClassContextSpecific || item.Tag != 3 { - continue - } - if found || !item.IsCompound { - return out, fmt.Errorf("invalid certificate extensions") - } - found = true - rest, err := asn1.Unmarshal(item.Bytes, &extensions) - if err != nil || len(rest) != 0 { - return out, fmt.Errorf("invalid certificate extensions") +// parseSignatureCertificate 解析唯一的PEM或DER证书 +// 入参: data 证书数据 +// 返回: *smx509.Certificate 证书, error 错误信息 +func parseSignatureCertificate(data []byte) (*smx509.Certificate, error) { + if block, rest := pem.Decode(data); block != nil { + if block.Type != "CERTIFICATE" || len(block.Headers) != 0 || len(bytes.TrimSpace(rest)) != 0 { + return nil, fmt.Errorf("expected one certificate") } + data = block.Bytes } - seen := make(map[string]bool) - for _, extension := range extensions { - oid := extension.ID.String() - if seen[oid] { - return out, fmt.Errorf("duplicate certificate extension: %s", oid) - } - seen[oid] = true - switch oid { - case signatureExtensionBasicConstraints: - isCA, maxPathLen, err := parseSignatureBasicConstraints(extension.Value) - if err != nil { - return out, err - } - out.IsCA = isCA - out.MaxPathLen = maxPathLen - case signatureExtensionKeyUsage: - keyUsage, err := parseSignatureKeyUsage(extension.Value) - if err != nil { - return out, err - } - out.KeyUsage = keyUsage - default: - if extension.Critical { - out.UnhandledCritical = true - } - } - } - return out, nil -} - -// parseSignatureBasicConstraints 解析证书基本约束 -// 入参: data 扩展DER数据 -// 返回: bool 是否为CA, *big.Int 路径长度限制, error 错误信息 -func parseSignatureBasicConstraints(data []byte) (bool, *big.Int, error) { - var raw asn1.RawValue - rest, err := asn1.Unmarshal(data, &raw) - if err != nil || len(rest) != 0 || raw.Tag != asn1.TagSequence || !raw.IsCompound { - return false, nil, fmt.Errorf("invalid basic constraints") - } - items, ok := asn1Children(raw.Bytes) - if !ok || len(items) > 2 { - return false, nil, fmt.Errorf("invalid basic constraints") - } - idx := 0 - isCA := false - if len(items) > 0 && items[0].Tag == asn1.TagBoolean { - rest, err := asn1.Unmarshal(items[0].FullBytes, &isCA) - if err != nil || len(rest) != 0 { - return false, nil, fmt.Errorf("invalid basic constraints") - } - idx++ - } - var maxPathLen *big.Int - if idx < len(items) { - maxPathLen, err = asn1IntegerBig(items[idx]) - if err != nil || maxPathLen.Sign() < 0 { - return false, nil, fmt.Errorf("invalid basic constraints") - } - idx++ - } - if idx != len(items) || (maxPathLen != nil && !isCA) { - return false, nil, fmt.Errorf("invalid basic constraints") - } - return isCA, maxPathLen, nil -} - -// parseSignatureKeyUsage 解析证书密钥用途 -// 入参: data 扩展DER数据 -// 返回: x509.KeyUsage 密钥用途, error 错误信息 -func parseSignatureKeyUsage(data []byte) (x509.KeyUsage, error) { - var bits asn1.BitString - rest, err := asn1.Unmarshal(data, &bits) - if err != nil || len(rest) != 0 || bits.BitLength == 0 || bits.BitLength > 9 { - return 0, fmt.Errorf("invalid key usage") - } - var out x509.KeyUsage - for i := 0; i < bits.BitLength; i++ { - if bits.At(i) != 0 { - out |= 1 << uint(i) - } - } - if out == 0 { - return 0, fmt.Errorf("invalid key usage") - } - return out, nil -} - -// parseCertificateValidity 解析证书有效期 -// 入参: raw 证书有效期ASN.1值 -// 返回: [2]time.Time 生效和失效时间, error 错误信息 -func parseCertificateValidity(raw asn1.RawValue) ([2]time.Time, error) { - items, ok := asn1Children(raw.Bytes) - if !ok || len(items) != 2 { - return [2]time.Time{}, fmt.Errorf("invalid certificate validity") - } - notBefore, err := asn1Time(items[0]) - if err != nil { - return [2]time.Time{}, err - } - notAfter, err := asn1Time(items[1]) - if err != nil { - return [2]time.Time{}, err - } - return [2]time.Time{notBefore, notAfter}, nil + return smx509.ParseCertificate(data) } // asn1Time 解析ASN.1时间 @@ -1164,82 +981,15 @@ func asn1Time(raw asn1.RawValue) (time.Time, error) { return t, nil } -// certificateNameValues 解析证书名称字段 -// 入参: raw 名称原始值 -// 返回: map[string][]string OID字段列表 -func certificateNameValues(raw asn1.RawValue) map[string][]string { - out := make(map[string][]string) - sets, ok := asn1Children(raw.Bytes) - if !ok { - return out - } - for _, set := range sets { - attrs, ok := asn1Children(set.Bytes) - if !ok { - continue - } - for _, attr := range attrs { - items, ok := asn1Children(attr.Bytes) - if !ok || len(items) < 2 { - continue - } - oid, err := asn1OIDString(items[0]) - if err != nil { - continue - } - if value := strings.TrimSpace(asn1String(items[1])); value != "" { - out[oid] = append(out[oid], value) - } - } - } - return out -} - -// certificateNameFirst 获取证书名称字段首值 -// 入参: values OID字段列表, oid 字段OID -// 返回: string 字段值 -func certificateNameFirst(values map[string][]string, oid string) string { - if items := values[oid]; len(items) > 0 { - return items[0] - } - return "" -} - -// certificateNameString 格式化证书名称 -// 入参: values OID字段列表 -// 返回: string 证书名称 -func certificateNameString(values map[string][]string) string { - var parts []string - for _, item := range []struct { - OID string - Label string - }{ - {"2.5.4.3", "CN"}, - {"2.5.4.10", "O"}, - {"2.5.4.11", "OU"}, - {"2.5.4.6", "C"}, - {"2.5.4.8", "ST"}, - {"2.5.4.7", "L"}, - {"2.5.4.5", "SN"}, - {"1.2.840.113549.1.9.1", "E"}, - } { - for _, value := range values[item.OID] { - parts = append(parts, item.Label+"="+value) - } - } - return strings.Join(parts, ", ") -} - // parseSignatureCerts 解析签名验证证书 // 入参: data DER或PEM编码证书 // 返回: [][]byte DER编码证书列表 func parseSignatureCerts(data []byte) [][]byte { - data = bytes.TrimSpace(data) if len(data) == 0 { return nil } var certs [][]byte - rest := data + rest := bytes.TrimSpace(data) hasPEM := false for { block, next := pem.Decode(rest) diff --git a/ofdgo_sign_write.go b/ofdgo_sign_write.go new file mode 100644 index 0000000..5584586 --- /dev/null +++ b/ofdgo_sign_write.go @@ -0,0 +1,503 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "archive/zip" + "bytes" + "crypto" + "encoding/base64" + "encoding/xml" + "fmt" + "io" + "path" + "slices" + "strconv" + "strings" + "time" +) + +// SignatureWriteMode 签署方式 +type SignatureWriteMode uint8 + +const ( + // SignatureAppend 追加签署,要求原活动签名有效且未保护签名列表 + SignatureAppend SignatureWriteMode = iota + // SignatureReplace 重新签署,替换全部活动签名,保留原签名数据文件 + SignatureReplace +) + +// SignatureWriteOptions 最终包签署选项,不修改Editor或调用方的原件 +// SM2数字签名使用GB/T 35275-2017消息结构;RSA/ECDSA使用裸签名,验签需WithSignatureCert +// Seal仅接受已有合法SES v4电子印章,使用SM2/SM3,不声明GM/T 0031-2025兼容 +// TrustRoots必须显式指定;证书吊销状态和外部业务授权由调用方在签署前检查 +type SignatureWriteOptions struct { + Signer crypto.Signer + Certificate []byte + TrustRoots [][]byte + Intermediates [][]byte + Seal []byte + Stamps []SignatureStamp + Mode SignatureWriteMode + // Time是自报签署时间,不是可信时间戳;零值取当前时间 + Time time.Time + Provider SignatureProvider + // Lock保护签名列表,禁止后续追加签署 + Lock bool + // ExistingVerifyOptions用于追加前与写出后回验,例如提供其他旧裸签名的证书 + ExistingVerifyOptions []SignatureVerifyOption + // ReaderOptions用于打开加密包;Encryption为空时继承阅读器的加密策略 + ReaderOptions []ReaderOption + Encryption *EncryptionOptions + progress editorProgress +} + +// SignPackage 签署最终OFD包,失败时返回nil且不修改输入 +// 入参: data 最终包字节, options 签署选项 +// 返回: []byte 签署后的完整包, error 错误信息 +func SignPackage(data []byte, options SignatureWriteOptions) ([]byte, error) { + r, err := NewReader(bytes.NewReader(data), int64(len(data)), options.ReaderOptions...) + if err != nil { + return nil, err + } + return signatureWriteOutput(r, options) +} + +// SignEditorTo 将编辑器序列化为最终文件后签署,保留或显式覆盖加密策略 +// 不修改编辑器状态;OnWriteProgress可在准备、签署和加密阶段取消,准备失败不写入目标 +// 输出目标不得覆盖源文件;目标自身写入失败时调用方应丢弃部分输出 +// 入参: writer 输出目标, editor 编辑器, options 签署选项 +// 返回: int64 写出字节数, error 错误信息 +func SignEditorTo(writer io.Writer, editor *Editor, options SignatureWriteOptions) (int64, error) { + if writer == nil || editor == nil { + return 0, fmt.Errorf("nil signature editor or writer") + } + if options.Encryption == nil && editor.encryption != nil && editor.encryption.options == nil { + return 0, ErrEncryptionPolicyRequired + } + var plain bytes.Buffer + defer func() { clear(plain.Bytes()) }() + if _, err := editor.writePlaintext(&plain); err != nil { + return 0, err + } + r, err := NewReader(bytes.NewReader(plain.Bytes()), int64(plain.Len())) + if err != nil { + return 0, err + } + r.encryption = editor.encryption + options.progress = editorProgress(editor.OnWriteProgress) + return r.SignTo(writer, options) +} + +// SignTo 从阅读器包内容创建独立快照,完成签署和回验后写出 +// 不采用可变的Doc/OFD模型;调用方应先将Editor写出为最终包 +// 准备失败不写入writer;writer自身失败可能已写入部分输出,不保证目标原子替换 +// 入参: writer 独立的输出目标,不得覆盖源文件, options 签署选项 +// 返回: int64 写出字节数, error 错误信息 +func (r *Reader) SignTo(writer io.Writer, options SignatureWriteOptions) (int64, error) { + if writer == nil { + return 0, fmt.Errorf("nil signature writer") + } + data, err := signatureWriteOutput(r, options) + if err != nil { + return 0, err + } + n, err := writer.Write(data) + if err == nil && n != len(data) { + err = io.ErrShortWrite + } + return int64(n), err +} + +// signatureWriteOutput 签署后恢复加密策略,禁止加密来源静默输出明文 +// 入参: r 阅读器, options 签署选项 +// 返回: []byte 输出包, error 错误信息 +func signatureWriteOutput(r *Reader, options SignatureWriteOptions) ([]byte, error) { + if r == nil { + return nil, fmt.Errorf("nil signature reader") + } + encryption := options.Encryption + if encryption == nil && r.encryption != nil { + encryption = r.encryption.options + if encryption == nil { + return nil, ErrEncryptionPolicyRequired + } + } + if encryption != nil { + if err := validateEncryptionOptions(*encryption); err != nil { + return nil, err + } + } + parts, err := signatureWriteParts(r) + if err != nil { + return nil, err + } + if encryption != nil { + defer func() { + for _, data := range parts { + clear(data) + } + }() + } + if err := options.progress.report("sign", 0, 1); err != nil { + return nil, err + } + data, err := signatureWritePackage(parts, options) + if err != nil { + return nil, err + } + if err := options.progress.report("sign", 1, 1); err != nil { + clear(data) + return nil, err + } + if encryption != nil { + defer clear(data) + if options.progress != nil { + signed, err := NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return nil, err + } + files, err := signatureWriteParts(signed) + if err != nil { + return nil, err + } + defer func() { + for _, data := range files { + clear(data) + } + }() + return encryptPackageParts(files, *encryption, options.progress) + } + return EncryptPackage(data, *encryption) + } + return data, nil +} + +// signatureWriteParts 使用阅读器统一路径规则读取独立包快照 +// 入参: r 阅读器 +// 返回: map[string][]byte 包文件, error 错误信息 +func signatureWriteParts(r *Reader) (map[string][]byte, error) { + if r == nil { + return nil, fmt.Errorf("nil signature reader") + } + if err := r.indexPackage(); err != nil { + return nil, err + } + return r.packageData() +} + +// signatureWritePackage 基于未重新编码的文件字节创建签名 +// 签名目录采用Sign_N兼容OFDRW容器,标识使用独立的sN签章域 +// 入参: parts 独立包文件, options 签署选项 +// 返回: []byte 完整包, error 错误信息 +func signatureWritePackage(parts map[string][]byte, options SignatureWriteOptions) ([]byte, error) { + if options.Mode != SignatureAppend && options.Mode != SignatureReplace { + return nil, fmt.Errorf("unsupported signature write mode") + } + r := &Reader{files: parts} + if err := r.initRoot(); err != nil { + return nil, err + } + if len(r.OFD.DocBody) != 1 { + return nil, fmt.Errorf("signature writing requires exactly one document") + } + doc, err := r.Doc() + if err != nil { + return nil, err + } + if options.Time.IsZero() { + options.Time = time.Now() + } + options.Time = options.Time.UTC().Truncate(time.Second) + identity, err := signatureWriteIdentity(options) + if err != nil { + return nil, err + } + stamps, err := signatureWriteStamps(r, options.Stamps, len(options.Seal) != 0) + if err != nil { + return nil, err + } + var list Signatures + listPath := r.ResPath(doc.Signatures) + if doc.Signatures != "" { + data, ok := parts[listPath] + if !ok { + return nil, fmt.Errorf("signature list not found: %s", listPath) + } + if err := xml.Unmarshal(data, &list); err != nil { + return nil, err + } + } + oldCount := len(list.List) + verifyOptions := append([]SignatureVerifyOption(nil), options.ExistingVerifyOptions...) + verifyOptions = append(verifyOptions, WithSignatureCert(identity.cert.Raw)) + if options.Mode == SignatureAppend && oldCount > 0 { + reports, err := r.VerifySignatures(verifyOptions...) + if err != nil { + return nil, err + } + ids := make(map[string]bool) + for _, report := range reports { + if !report.Valid || report.StampPositionError != "" || ids[report.ID] || report.ID == "" { + return nil, fmt.Errorf("existing signature %s is invalid: %s", report.ID, report.Error) + } + ids[report.ID] = true + for _, ref := range report.References { + if ref.Path == listPath { + return nil, fmt.Errorf("signature %s protects the signature list", report.ID) + } + } + } + } + if doc.Signatures == "" { + listPath = signatureWriteAvailable(parts, path.Join(r.RootDir, "Signs", "Signatures.xml")) + root, err := parseEditorXML(parts["OFD.xml"]) + if err != nil { + return nil, err + } + body := root.child("DocBody") + if body == nil { + return nil, fmt.Errorf("missing document body") + } + parts["OFD.xml"] = editorXMLSetText(parts["OFD.xml"], body, [][2]string{{"Signatures", "/" + listPath}}) + } + if options.Mode == SignatureReplace { + list.List = nil + oldCount = 0 + } + id, stamps, err := signatureWriteIDs(&list, listPath, parts, stamps) + if err != nil { + return nil, err + } + directory := "" + for n := 1; ; n++ { + directory = path.Join(path.Dir(listPath), "Sign_"+strconv.Itoa(n)) + occupied := false + for name := range parts { + if strings.EqualFold(name, directory) || strings.HasPrefix(strings.ToLower(name), strings.ToLower(directory)+"/") { + occupied = true + break + } + } + if !occupied { + break + } + } + sigPath, valuePath, sealPath := path.Join(directory, "Signature.xml"), path.Join(directory, "SignedValue.dat"), path.Join(directory, "Seal.esl") + kind := SignTypeSign + if len(options.Seal) > 0 { + kind = SignTypeSeal + parts[sealPath] = bytes.Clone(options.Seal) + } + list.List = append(list.List, Signature{ID: id, Type: kind, BaseLoc: "/" + sigPath}) + parts[listPath], err = signatureWriteXML("Signatures", list) + if err != nil { + return nil, err + } + refs := SignatureReferences{CheckMethod: identity.digest} + names := make([]string, 0, len(parts)) + for name := range parts { + names = append(names, name) + } + slices.Sort(names) + for index, name := range names { + if err := options.progress.report("signature-references", index, len(names)); err != nil { + return nil, err + } + if name == listPath && !options.Lock { + continue + } + digest, err := signatureDigest(identity.digest, parts[name]) + if err != nil { + return nil, err + } + refs.Reference = append(refs.Reference, SignatureReference{FileRef: "/" + name, CheckValue: base64.StdEncoding.EncodeToString(digest)}) + } + if err := options.progress.report("signature-references", len(names), len(names)); err != nil { + return nil, err + } + provider := options.Provider + if provider.ProviderName == "" { + provider.ProviderName = "ofdgo" + } + info := signatureWriteInfo{Provider: provider, SignatureMethod: identity.method, SignatureDateTime: options.Time.Format(time.RFC3339), References: refs, Stamps: stamps} + if kind == SignTypeSeal { + info.Seal = &SignatureSeal{BaseLoc: "/" + sealPath} + } + file := struct { + Info signatureWriteInfo `xml:"SignedInfo"` + Value string `xml:"SignedValue"` + }{info, "/" + valuePath} + parts[sigPath], err = signatureWriteXML("Signature", file) + if err != nil { + return nil, err + } + parts[valuePath], err = identity.sign(parts[sigPath], "/"+sigPath, options) + if err != nil { + return nil, err + } + output, err := signatureWriteZIP(parts) + if err != nil { + return nil, err + } + check, err := NewReader(bytes.NewReader(output), int64(len(output))) + if err != nil { + return nil, err + } + reports, err := check.VerifySignatures(verifyOptions...) + if err != nil { + return nil, err + } + if len(reports) != oldCount+1 { + return nil, fmt.Errorf("signature verification count mismatch") + } + for _, report := range reports { + if !report.Valid || report.StampPositionError != "" { + return nil, fmt.Errorf("written signature %s failed verification: %s", report.ID, report.Error) + } + } + return output, nil +} + +// signatureWriteIDs 为签名和全部外观分配同一签章域的标识,不修改文档图元编号 +// 入参: list 签名列表, listPath 列表路径, parts 包文件, stamps 外观 +// 返回: string 签名标识, []SignatureStamp 外观, error 错误信息 +func signatureWriteIDs(list *Signatures, listPath string, parts map[string][]byte, stamps []SignatureStamp) (string, []SignatureStamp, error) { + used := make(map[string]bool) + var maximum uint64 + track := func(id string) { + n, err := strconv.ParseUint(strings.TrimPrefix(id, "s"), 10, 64) + if err == nil && n > maximum { + maximum = n + } + } + track(list.MaxSignID) + add := func(id string) error { + if !signatureWriteIDValid(id) || used[id] { + return fmt.Errorf("invalid or duplicate signature ID: %s", id) + } + used[id] = true + track(id) + return nil + } + for _, ref := range list.List { + if err := add(ref.ID); err != nil { + return "", nil, err + } + file, err := parseSignatureFile(parts[signatureRefPath(listPath, ref.BaseLoc)]) + if err != nil { + return "", nil, err + } + for _, stamp := range file.SignedInfo.StampAnnot { + if err := add(stamp.ID); err != nil { + return "", nil, err + } + } + } + for _, stamp := range stamps { + if stamp.ID != "" { + if err := add(stamp.ID); err != nil { + return "", nil, err + } + } + } + next := func() (string, error) { + if maximum == ^uint64(0) { + return "", fmt.Errorf("signature ID exhausted") + } + maximum++ + return "s" + strconv.FormatUint(maximum, 10), nil + } + id, err := next() + if err != nil { + return "", nil, err + } + for i := range stamps { + if stamps[i].ID == "" { + stamps[i].ID, err = next() + if err != nil { + return "", nil, err + } + } + } + list.MaxSignID = "s" + strconv.FormatUint(maximum, 10) + return id, stamps, nil +} + +// signatureWriteInfo 写出签名信息,省略数字签名中不存在的印章 +type signatureWriteInfo struct { + Provider SignatureProvider `xml:"Provider"` + SignatureMethod string `xml:"SignatureMethod"` + SignatureDateTime string `xml:"SignatureDateTime"` + References SignatureReferences `xml:"References"` + Stamps []SignatureStamp `xml:"StampAnnot,omitempty"` + Seal *SignatureSeal `xml:"Seal,omitempty"` +} + +// signatureWriteXML 编码带OFD命名空间的签名文件 +// 入参: name 根节点名称, value 内容 +// 返回: []byte XML, error 错误信息 +func signatureWriteXML(name string, value any) ([]byte, error) { + var out bytes.Buffer + out.WriteString(xml.Header) + err := xml.NewEncoder(&out).EncodeElement(value, xml.StartElement{Name: xml.Name{Space: ofdNamespace, Local: name}}) + return out.Bytes(), err +} + +// signatureWriteAvailable 分配不覆盖原文件的路径 +// 入参: parts 原包文件, name 候选路径 +// 返回: string 可用路径 +func signatureWriteAvailable(parts map[string][]byte, name string) string { + original := name + for i := 1; ; i++ { + found := false + for existing := range parts { + if strings.EqualFold(existing, name) { + found = true + break + } + } + if !found { + return name + } + name = path.Join(path.Dir(original), strconv.Itoa(i)+"_"+path.Base(original)) + } +} + +// signatureWriteZIP 写出完整包,不重新编码受保护文件 +// 入参: parts 文件集合 +// 返回: []byte 包字节, error 错误信息 +func signatureWriteZIP(parts map[string][]byte) ([]byte, error) { + var out bytes.Buffer + w := zip.NewWriter(&out) + names := make([]string, 0, len(parts)) + for name := range parts { + names = append(names, name) + } + slices.Sort(names) + for _, name := range names { + f, err := w.Create(name) + if err != nil { + return nil, err + } + if _, err := f.Write(parts[name]); err != nil { + return nil, err + } + } + if err := w.Close(); err != nil { + return nil, err + } + return out.Bytes(), nil +} diff --git a/ofdgo_sign_write_crypto.go b/ofdgo_sign_write_crypto.go new file mode 100644 index 0000000..98c3414 --- /dev/null +++ b/ofdgo_sign_write_crypto.go @@ -0,0 +1,334 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "bytes" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/pem" + "fmt" + "math/big" + "reflect" + "time" + + "github.com/emmansun/gmsm/pkcs8" + "github.com/emmansun/gmsm/sm2" + "github.com/emmansun/gmsm/sm3" + "github.com/emmansun/gmsm/smx509" +) + +// ParseSignatureIdentity 解析签署私钥和证书,并检查公私钥对应关系 +// 支持普通PKCS8、SEC1、PKCS1以及加密PKCS8,不支持传统加密PEM +// 不加载系统信任或暗中信任该证书,返回的SM2/RSA私钥同时实现crypto.Decrypter +// 入参: certificate 证书DER或PEM, privateKey 私钥DER或PEM, password 可选的单一私钥口令 +// 返回: crypto.Signer 签署器, []byte 证书DER, error 错误信息 +func ParseSignatureIdentity(certificate, privateKey []byte, password ...[]byte) (crypto.Signer, []byte, error) { + if len(password) > 1 { + return nil, nil, fmt.Errorf("expected at most one private key password") + } + cert, err := parseSignatureCertificate(certificate) + if err != nil { + return nil, nil, err + } + der := privateKey + encrypted := false + if block, rest := pem.Decode(privateKey); block != nil { + if len(bytes.TrimSpace(rest)) != 0 || len(block.Headers) != 0 { + return nil, nil, fmt.Errorf("legacy encrypted PEM or multiple private keys are unsupported") + } + encrypted = block.Type == "ENCRYPTED PRIVATE KEY" + if !encrypted && block.Type != "PRIVATE KEY" && block.Type != "RSA PRIVATE KEY" && block.Type != "EC PRIVATE KEY" { + return nil, nil, fmt.Errorf("unsupported private key PEM type") + } + der = block.Bytes + } + var raw asn1.RawValue + if rest, err := asn1.Unmarshal(der, &raw); err != nil || len(rest) != 0 || raw.Tag != asn1.TagSequence || raw.Class != asn1.ClassUniversal { + return nil, nil, fmt.Errorf("invalid private key encoding") + } + var pass []byte + if len(password) == 1 { + pass = bytes.Clone(password[0]) + defer clear(pass) + } + if encrypted && len(pass) == 0 { + return nil, nil, fmt.Errorf("private key password required") + } + key, err := pkcs8.ParsePKCS8PrivateKey(der, pass) + if err != nil && !encrypted { + key, err = smx509.ParsePKCS8PrivateKey(der) + if err != nil { + key, err = smx509.ParseTypedECPrivateKey(der) + } + if err != nil { + key, err = x509.ParsePKCS1PrivateKey(der) + } + } + if err != nil { + return nil, nil, fmt.Errorf("invalid private key or incorrect password") + } + signer, ok := key.(crypto.Signer) + if !ok { + return nil, nil, fmt.Errorf("unsupported signature private key") + } + if _, _, err := signatureWriteAlgorithm(signer, cert); err != nil { + return nil, nil, err + } + return signer, bytes.Clone(cert.Raw), nil +} + +// signatureWriteKey 已验证签署身份 +type signatureWriteKey struct { + cert *smx509.Certificate + method string + digest string +} + +// signatureWriteIdentity 检查签署身份、证书信任与印章授权 +// 制章证书按制章时间验证,不以签署时间代替制章时间 +// 入参: options 签署选项 +// 返回: *signatureWriteKey 已验证身份, error 错误信息 +func signatureWriteIdentity(options SignatureWriteOptions) (*signatureWriteKey, error) { + cert, err := parseSignatureCertificate(options.Certificate) + if err != nil { + return nil, err + } + method, digest, err := signatureWriteAlgorithm(options.Signer, cert) + if err != nil { + return nil, err + } + if err := signatureWriteTrust(cert, options.Time, options); err != nil { + return nil, fmt.Errorf("signer certificate: %w", err) + } + if len(options.Seal) > 0 { + if method != signMethodSM2SM3 { + return nil, fmt.Errorf("SES v4 writing requires SM2/SM3") + } + var raw asn1.RawValue + rest, err := asn1.Unmarshal(options.Seal, &raw) + if err != nil || len(rest) != 0 || raw.Tag != asn1.TagSequence || raw.Class != asn1.ClassUniversal { + return nil, fmt.Errorf("invalid SES seal DER; image files are not electronic seals") + } + if err := signatureWriteSealStructure(raw); err != nil { + return nil, err + } + seal, err := parseSESSeal(raw) + if err != nil { + return nil, err + } + if seal.Info.Version != 4 || seal.SignAlg != signMethodSM2SM3 { + return nil, fmt.Errorf("only SES v4 SM2/SM3 seals are supported") + } + if !sesCertInList(cert.Raw, seal.CertList) { + return nil, fmt.Errorf("signer certificate is not authorized by the seal") + } + if seal.Info.CreateTime.IsZero() || seal.Info.ValidStart.IsZero() || seal.Info.ValidEnd.IsZero() || options.Time.Before(seal.Info.CreateTime) || options.Time.Before(seal.Info.ValidStart) || options.Time.After(seal.Info.ValidEnd) || seal.Info.ValidEnd.Before(seal.Info.ValidStart) { + return nil, fmt.Errorf("seal is not valid at signature time") + } + maker, err := parseSignatureCertificate(seal.Cert) + if err != nil { + return nil, err + } + if err := signatureWriteTrust(maker, seal.Info.CreateTime, options); err != nil { + return nil, fmt.Errorf("seal maker certificate: %w", err) + } + pub, ok := maker.PublicKey.(*ecdsa.PublicKey) + if !ok || !sm2.IsSM2PublicKey(pub) || !sm2.VerifyASN1WithSM2(pub, nil, seal.SignData, seal.Signature) { + return nil, fmt.Errorf("invalid seal maker signature") + } + mediaType, media := probeSealMedia(seal.PicData) + if len(media) == 0 || mediaType != normalizeSealType(seal.PicType) { + return nil, fmt.Errorf("invalid seal picture") + } + } + return &signatureWriteKey{cert: cert, method: method, digest: digest}, nil +} + +// signatureWriteSealStructure 限定SES v4印章结构,拒绝旧版容器伪装和无效图像尺寸 +// 入参: raw 印章DER节点 +// 返回: error 错误信息 +func signatureWriteSealStructure(raw asn1.RawValue) error { + items, ok := asn1Children(raw.Bytes) + if !ok || len(items) != 4 || items[0].Tag != asn1.TagSequence || items[1].Tag != asn1.TagOctetString || items[2].Tag != asn1.TagOID || items[3].Tag != asn1.TagBitString { + return fmt.Errorf("unsupported SES v4 seal structure") + } + for _, item := range items { + if item.Class != asn1.ClassUniversal { + return fmt.Errorf("invalid SES v4 seal tag") + } + } + info, ok := asn1Children(items[0].Bytes) + if !ok || len(info) < 4 || len(info) > 5 { + return fmt.Errorf("invalid SES v4 seal info") + } + header, ok := asn1Children(info[0].Bytes) + if !ok || len(header) != 3 || header[0].Tag != asn1.TagIA5String || header[2].Tag != asn1.TagIA5String || info[1].Tag != asn1.TagIA5String { + return fmt.Errorf("invalid SES v4 seal header") + } + picture, ok := asn1Children(info[3].Bytes) + if !ok || len(picture) != 4 || picture[0].Tag != asn1.TagIA5String || picture[1].Tag != asn1.TagOctetString { + return fmt.Errorf("invalid SES v4 picture structure") + } + w, err := asn1Integer(picture[2]) + if err != nil || w <= 0 { + return fmt.Errorf("invalid SES v4 picture width") + } + h, err := asn1Integer(picture[3]) + if err != nil || h <= 0 { + return fmt.Errorf("invalid SES v4 picture height") + } + return nil +} + +// signatureWriteAlgorithm 校验证书、公钥匹配和支持的安全算法 +// 入参: signer 签署器, cert 证书 +// 返回: string 签名算法OID, string 摘要OID, error 错误信息 +func signatureWriteAlgorithm(signer crypto.Signer, cert *smx509.Certificate) (string, string, error) { + if signer == nil || reflect.ValueOf(signer).Kind() == reflect.Ptr && reflect.ValueOf(signer).IsNil() { + return "", "", fmt.Errorf("nil signature signer") + } + pub := signer.Public() + match, ok := cert.PublicKey.(interface{ Equal(crypto.PublicKey) bool }) + if !ok || !match.Equal(pub) { + return "", "", fmt.Errorf("signature certificate and private key do not match") + } + switch key := pub.(type) { + case *rsa.PublicKey: + if key.N == nil || key.N.BitLen() < 2048 || key.E < 65537 { + return "", "", fmt.Errorf("RSA signature key must be at least 2048 bits with exponent at least 65537") + } + return "1.2.840.113549.1.1.11", "2.16.840.1.101.3.4.2.1", nil + case *ecdsa.PublicKey: + if sm2.IsSM2PublicKey(key) { + return signMethodSM2SM3, signDigestSM3, nil + } + if key.Curve != elliptic.P256() && key.Curve != elliptic.P384() && key.Curve != elliptic.P521() { + return "", "", fmt.Errorf("unsupported ECDSA signature curve") + } + return "1.2.840.10045.4.3.2", "2.16.840.1.101.3.4.2.1", nil + default: + return "", "", fmt.Errorf("unsupported signature public key") + } +} + +// signatureWriteTrust 校验证书用途、有效期和调用方显式指定的信任链 +// 入参: cert 证书, at 验证时间, options 签署选项 +// 返回: error 错误信息 +func signatureWriteTrust(cert *smx509.Certificate, at time.Time, options SignatureWriteOptions) error { + if cert.KeyUsage != 0 && cert.KeyUsage&(smx509.KeyUsageDigitalSignature|smx509.KeyUsageContentCommitment) == 0 { + return fmt.Errorf("certificate does not authorize digital signatures") + } + if len(options.TrustRoots) == 0 { + return fmt.Errorf("explicit signature trust roots are required") + } + return verifySignatureCertificateChain(cert, options.Intermediates, options.TrustRoots, at, smx509.ExtKeyUsageAny) +} + +// sign 编码数字签名或SES v4签章 +// 入参: data 最终Signature.xml原文, property 包内绝对路径, options 签署选项 +// 返回: []byte 签名值, error 错误信息 +func (key *signatureWriteKey) sign(data []byte, property string, options SignatureWriteOptions) ([]byte, error) { + if len(options.Seal) == 0 { + if key.method != signMethodSM2SM3 { + return key.signMessage(data, options.Signer) + } + digest := sm3.Sum(data) + signature, err := key.signMessage(digest[:], options.Signer) + if err != nil { + return nil, err + } + return signatureWriteSignedData(key.cert, digest[:], signature) + } + digest := sm3.Sum(data) + tbs, err := asn1.Marshal(struct { + Version int + Seal asn1.RawValue + Time time.Time `asn1:"generalized"` + Hash asn1.BitString + Property string `asn1:"ia5"` + }{4, asn1.RawValue{FullBytes: options.Seal}, options.Time, asn1.BitString{Bytes: digest[:], BitLength: 256}, property}) + if err != nil { + return nil, err + } + signature, err := key.signMessage(tbs, options.Signer) + if err != nil { + return nil, err + } + return asn1.Marshal(struct { + TBS asn1.RawValue + Certificate []byte + Algorithm asn1.ObjectIdentifier + Signature asn1.BitString + }{asn1.RawValue{FullBytes: tbs}, key.cert.Raw, asn1.ObjectIdentifier{1, 2, 156, 10197, 1, 501}, asn1.BitString{Bytes: signature, BitLength: len(signature) * 8}}) +} + +// signMessage 通过crypto.Signer调用成熟实现或硬件签署器 +// SM2签署器必须接受gmsm的SM2SignerOption,接收原文并计算默认用户标识的ZA +// 入参: data 原文, signer 签署器 +// 返回: []byte 签名值, error 错误信息 +func (key *signatureWriteKey) signMessage(data []byte, signer crypto.Signer) ([]byte, error) { + if key.method == signMethodSM2SM3 { + return signer.Sign(rand.Reader, data, sm2.NewSM2SignerOption(true, nil)) + } + digest := sha256.Sum256(data) + return signer.Sign(rand.Reader, digest[:], crypto.SHA256) +} + +// signatureWriteSignedData 编码带签署证书的SM2数字签名消息 +// 入参: cert 证书, digest 文档描述摘要, signature SM2签名值 +// 返回: []byte SignedData编码, error 错误信息 +func signatureWriteSignedData(cert *smx509.Certificate, digest, signature []byte) ([]byte, error) { + type contentInfo struct { + Type asn1.ObjectIdentifier + Content asn1.RawValue + } + type signerInfo struct { + Version int + IssuerAndSerial struct { + Issuer asn1.RawValue + Serial *big.Int + } + Digest pkix.AlgorithmIdentifier + Algorithm pkix.AlgorithmIdentifier + Signature []byte + } + digestAlg := pkix.AlgorithmIdentifier{Algorithm: asn1.ObjectIdentifier{1, 2, 156, 10197, 1, 401}, Parameters: asn1.NullRawValue} + signer := signerInfo{Version: 1, Digest: digestAlg, Algorithm: pkix.AlgorithmIdentifier{Algorithm: asn1.ObjectIdentifier{1, 2, 156, 10197, 1, 301, 1}}, Signature: signature} + signer.IssuerAndSerial.Issuer = asn1.RawValue{FullBytes: cert.RawIssuer} + signer.IssuerAndSerial.Serial = cert.SerialNumber + content, err := asn1.Marshal(digest) + if err != nil { + return nil, err + } + sd, err := asn1.Marshal(struct { + Version int + Digests []pkix.AlgorithmIdentifier `asn1:"set"` + Content contentInfo + Certificates asn1.RawValue + Signers []signerInfo `asn1:"set"` + }{1, []pkix.AlgorithmIdentifier{digestAlg}, contentInfo{asn1.ObjectIdentifier{1, 2, 156, 10197, 6, 1, 4, 2, 1}, asn1.RawValue{Class: 2, Tag: 0, IsCompound: true, Bytes: content}}, asn1.RawValue{Class: 2, Tag: 0, IsCompound: true, Bytes: cert.Raw}, []signerInfo{signer}}) + if err != nil { + return nil, err + } + return asn1.Marshal(contentInfo{asn1.ObjectIdentifier{1, 2, 156, 10197, 6, 1, 4, 2, 2}, asn1.RawValue{Class: 2, Tag: 0, IsCompound: true, Bytes: sd}}) +} diff --git a/ofdgo_sign_write_stamp.go b/ofdgo_sign_write_stamp.go new file mode 100644 index 0000000..dd6989c --- /dev/null +++ b/ofdgo_sign_write_stamp.go @@ -0,0 +1,147 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "fmt" + "math" + "strconv" + "strings" +) + +// SignatureSeamStamps 生成页面右侧的等宽骑缝印章位置 +// 页码从1开始,按传入顺序分割同一印章;box.X为右侧内缩,Y为纵坐标,W/H为完整印章尺寸 +// 入参: pages 至少两页且不重复, box 印章参数,单位毫米 +// 返回: []SignatureStamp 可传入签署选项的位置, error 错误信息 +func (r *Reader) SignatureSeamStamps(pages []int, box Box) ([]SignatureStamp, error) { + if r == nil || len(pages) < 2 || !signatureWriteBoxValid(box) || box.X < 0 { + return nil, fmt.Errorf("invalid seam stamp parameters") + } + doc, err := r.Doc() + if err != nil { + return nil, err + } + seen := make(map[int]bool) + stamps := make([]SignatureStamp, 0, len(pages)) + width := box.W / float64(len(pages)) + for i, page := range pages { + if page < 1 || page > len(doc.Pages.Page) || seen[page] { + return nil, fmt.Errorf("invalid seam stamp page: %d", page) + } + seen[page] = true + area, err := r.PageArea(doc.Pages.Page[page-1]) + if err != nil { + return nil, err + } + physical, err := ParseBox(area.PhysicalBox) + if err != nil { + return nil, err + } + clip := Box{X: float64(i) * width, W: width, H: box.H} + boundary := Box{X: physical.X + physical.W - box.X - width - clip.X, Y: box.Y, W: box.W, H: box.H} + stamps = append(stamps, SignatureStamp{PageRef: doc.Pages.Page[page-1].ID, Boundary: signatureWriteBox(boundary), Clip: signatureWriteBox(clip)}) + } + return signatureWriteStamps(r, stamps, true) +} + +// signatureWriteStamps 复用Reader定位并检查外观几何参数 +// 入参: r 阅读器, stamps 位置, seal 是否电子印章 +// 返回: []SignatureStamp 独立位置列表, error 错误信息 +func signatureWriteStamps(r *Reader, stamps []SignatureStamp, seal bool) ([]SignatureStamp, error) { + if len(stamps) != 0 && !seal { + return nil, fmt.Errorf("digital signatures cannot contain seal appearances") + } + positions, err := r.SignatureStampPositions(stamps) + if err != nil { + return nil, err + } + doc, err := r.Doc() + if err != nil { + return nil, err + } + result := append([]SignatureStamp(nil), stamps...) + if len(result) != 0 { + pages := make(map[string]bool) + for _, page := range doc.Pages.Page { + if page.ID == "" || pages[page.ID] { + return nil, fmt.Errorf("ambiguous stamp page ID: %s", page.ID) + } + pages[page.ID] = true + } + } + ids := make(map[string]bool) + for _, stamp := range stamps { + if stamp.ID == "" { + continue + } + if !signatureWriteIDValid(stamp.ID) || ids[stamp.ID] { + return nil, fmt.Errorf("invalid or duplicate stamp ID: %s", stamp.ID) + } + ids[stamp.ID] = true + } + for _, position := range positions { + box := position.Box + if !signatureWriteBoxValid(box) { + return nil, fmt.Errorf("invalid stamp boundary") + } + visible := box + if clip := position.ClipBox; clip != nil { + if !signatureWriteBoxValid(*clip) || clip.X < 0 || clip.Y < 0 || clip.X+clip.W > box.W+1e-9 || clip.Y+clip.H > box.H+1e-9 { + return nil, fmt.Errorf("invalid stamp clip") + } + visible = Box{X: box.X + clip.X, Y: box.Y + clip.Y, W: clip.W, H: clip.H} + } + area, err := r.PageArea(doc.Pages.Page[position.Page-1]) + if err != nil { + return nil, err + } + page, err := ParseBox(area.PhysicalBox) + if err != nil || !signatureWriteBoxValid(page) { + return nil, fmt.Errorf("invalid stamp page area") + } + if visible.X < page.X-1e-9 || visible.Y < page.Y-1e-9 || visible.X+visible.W > page.X+page.W+1e-9 || visible.Y+visible.H > page.Y+page.H+1e-9 { + return nil, fmt.Errorf("stamp is outside page %d", position.Page) + } + } + return result, nil +} + +// signatureWriteIDValid 检查可互通的签章标识字符 +// 入参: id 标识 +// 返回: bool 是否有效 +func signatureWriteIDValid(id string) bool { + return id != "" && len(id) <= 128 && strings.IndexFunc(id, func(c rune) bool { + return !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-' || c == '.') + }) == -1 +} + +// signatureWriteBoxValid 判断印章区域是否有限且尺寸为正 +// 入参: box 区域 +// 返回: bool 是否有效 +func signatureWriteBoxValid(box Box) bool { + for _, value := range []float64{box.X, box.Y, box.W, box.H} { + if math.IsInf(value, 0) || math.IsNaN(value) { + return false + } + } + return box.W > 0 && box.H > 0 +} + +// signatureWriteBox 编码印章区域 +// 入参: box 区域 +// 返回: string 区域文本 +func signatureWriteBox(box Box) string { + return strconv.FormatFloat(box.X, 'f', -1, 64) + " " + strconv.FormatFloat(box.Y, 'f', -1, 64) + " " + strconv.FormatFloat(box.W, 'f', -1, 64) + " " + strconv.FormatFloat(box.H, 'f', -1, 64) +} diff --git a/ofdgo_writer.go b/ofdgo_writer.go index d34b400..63397e8 100644 --- a/ofdgo_writer.go +++ b/ofdgo_writer.go @@ -50,6 +50,16 @@ func (progress editorProgress) report(stage string, completed, total int) error // 入参: writer 输出流 // 返回: int64 已写入字节数, error 错误信息 func (e *Editor) WriteTo(writer io.Writer) (int64, error) { + if e.encryption != nil { + return e.writeEncrypted(writer) + } + return e.writePlaintext(writer) +} + +// writePlaintext 写出未加密包,供显式明文输出与加密封装共用 +// 入参: writer 输出流 +// 返回: int64 写入字节数, error 错误信息 +func (e *Editor) writePlaintext(writer io.Writer) (int64, error) { if err := e.validate(); err != nil { return 0, err } @@ -129,6 +139,7 @@ func (e *Editor) WritePagesTo(writer io.Writer, indexes []int) (int64, error) { } defer reader.Close() selected := NewEditor() + selected.encryption = e.encryption id := selected.Info.DocID selected.Info = cloneEditorData(e.Info) selected.Info.DocID = id @@ -156,7 +167,7 @@ func (e *Editor) reader(progress editorProgress) (*Reader, error) { if e.source != nil { return e.sourceReader(progress) } - r := &Reader{files: make(map[string][]byte)} + r := &Reader{files: make(map[string][]byte), encryption: e.encryption} if err := e.writeParts(func(name string, data []byte, _ bool) error { r.files[name] = data return nil