@bdking71/spsignature 1.0.5 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +182 -45
- package/lib/OtpService.d.ts +24 -0
- package/lib/OtpService.js +53 -0
- package/lib/ProvisioningService.d.ts +50 -1
- package/lib/ProvisioningService.js +84 -26
- package/lib/TransactionSigner.d.ts +109 -2
- package/lib/TransactionSigner.js +437 -75
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/package.json +34 -16
- package/lib/VerificationService.d.ts +0 -16
- package/lib/VerificationService.js +0 -36
package/lib/TransactionSigner.js
CHANGED
|
@@ -1,14 +1,78 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @file SecureAuditSignature.ts
|
|
4
|
+
*
|
|
5
|
+
* Provides a secure, auditable digital-signature workflow for SharePoint
|
|
6
|
+
* web parts. The module renders a modal dialog that supports:
|
|
7
|
+
*
|
|
8
|
+
* • Drawing a signature on an HTML canvas
|
|
9
|
+
* • Uploading a signature image (PNG / JPG, ≤ 5 MB)
|
|
10
|
+
* • Re-using a previously cached (LZW-compressed) signature from
|
|
11
|
+
* localStorage
|
|
12
|
+
* • Optional two-factor authentication via a 5-digit passcode
|
|
13
|
+
* dispatched through email or Microsoft Teams
|
|
14
|
+
*
|
|
15
|
+
* After the user signs, the module hashes the audit envelope
|
|
16
|
+
* (payload + signer + timestamp) with SHA-256 so that the record can
|
|
17
|
+
* later be verified without exposing the original payload.
|
|
18
|
+
*
|
|
19
|
+
* @module SecureAuditSignature
|
|
20
|
+
*/
|
|
2
21
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
22
|
exports.verifySecureAuditRecord = exports.getReportableSignature = exports.promptAndGenerateSecureAudit = void 0;
|
|
4
|
-
const
|
|
23
|
+
const OtpService_1 = require("./OtpService");
|
|
24
|
+
/** localStorage key used to persist a compressed signature across sessions. */
|
|
5
25
|
const STORAGE_KEY = "secure_audit_cached_signature_v1";
|
|
26
|
+
/** Maximum upload file size in bytes (5 MB). */
|
|
27
|
+
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Helpers – passcode generation
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
/**
|
|
32
|
+
* Generates a cryptographically random 5-digit passcode (10 000 – 99 999)
|
|
33
|
+
* using the Web Crypto API.
|
|
34
|
+
*
|
|
35
|
+
* @returns A string representation of the 5-digit code.
|
|
36
|
+
*/
|
|
6
37
|
function generateFiveDigitPasscode() {
|
|
7
38
|
const array = new Uint32Array(1);
|
|
8
39
|
window.crypto.getRandomValues(array);
|
|
9
40
|
const code = (array[0] % 90000) + 10000;
|
|
10
41
|
return code.toString();
|
|
11
42
|
}
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Main public function – modal signing ceremony
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
/**
|
|
47
|
+
* Opens a full-screen modal dialog that walks the user through:
|
|
48
|
+
*
|
|
49
|
+
* 1. (Optional) Two-factor passcode verification
|
|
50
|
+
* 2. Providing a digital signature (cached / drawn / uploaded)
|
|
51
|
+
* 3. Generating a tamper-evident SHA-256 audit record
|
|
52
|
+
*
|
|
53
|
+
* The returned promise resolves with a `SharePointAuditRecord` on
|
|
54
|
+
* success or `undefined` if the user cancels.
|
|
55
|
+
*
|
|
56
|
+
* @param context - Signer metadata and SPFx context.
|
|
57
|
+
* @param modalTitle - Title shown in the modal header.
|
|
58
|
+
* @param warningMessage - Instructional HTML rendered above the
|
|
59
|
+
* signature area.
|
|
60
|
+
* @returns A promise that resolves to the audit record or `undefined`.
|
|
61
|
+
*
|
|
62
|
+
* @throws {Error} If `context.spContext` is falsy.
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* ```ts
|
|
66
|
+
* const record = await promptAndGenerateSecureAudit({
|
|
67
|
+
* itemID: 42,
|
|
68
|
+
* signer: currentUser.email,
|
|
69
|
+
* payload: { amount: 1500, vendor: "Contoso" },
|
|
70
|
+
* spContext: this.context,
|
|
71
|
+
* channel: "email",
|
|
72
|
+
* requireTFA: true,
|
|
73
|
+
* });
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
12
76
|
async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purchase Requisition", warningMessage = "Please review your entry and apply your signature to finalize this record.") {
|
|
13
77
|
if (!context.spContext) {
|
|
14
78
|
throw new Error("Execution restricted: Valid WebPartContext must be supplied.");
|
|
@@ -16,7 +80,10 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
|
|
|
16
80
|
return new Promise((resolve) => {
|
|
17
81
|
const requireTFA = context.requireTFA !== false;
|
|
18
82
|
const generatedPasscode = generateFiveDigitPasscode();
|
|
19
|
-
let storedVerificationItemId =
|
|
83
|
+
let storedVerificationItemId = undefined;
|
|
84
|
+
// -----------------------------------------------------------------
|
|
85
|
+
// Overlay
|
|
86
|
+
// -----------------------------------------------------------------
|
|
20
87
|
const overlay = document.createElement("div");
|
|
21
88
|
overlay.style.position = "fixed";
|
|
22
89
|
overlay.style.top = "0";
|
|
@@ -28,8 +95,12 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
|
|
|
28
95
|
overlay.style.display = "flex";
|
|
29
96
|
overlay.style.alignItems = "center";
|
|
30
97
|
overlay.style.justifyContent = "center";
|
|
31
|
-
overlay.style.fontFamily =
|
|
98
|
+
overlay.style.fontFamily =
|
|
99
|
+
"'Segoe UI', Tahoma, Geneva, Verdana, sans-serif";
|
|
32
100
|
overlay.style.backdropFilter = "blur(3px)";
|
|
101
|
+
// -----------------------------------------------------------------
|
|
102
|
+
// Modal container
|
|
103
|
+
// -----------------------------------------------------------------
|
|
33
104
|
const modalBox = document.createElement("div");
|
|
34
105
|
modalBox.style.backgroundColor = "#ffffff";
|
|
35
106
|
modalBox.style.padding = "0";
|
|
@@ -41,6 +112,9 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
|
|
|
41
112
|
modalBox.style.maxHeight = "90vh";
|
|
42
113
|
modalBox.style.display = "flex";
|
|
43
114
|
modalBox.style.flexDirection = "column";
|
|
115
|
+
// -----------------------------------------------------------------
|
|
116
|
+
// Header
|
|
117
|
+
// -----------------------------------------------------------------
|
|
44
118
|
const headerSection = document.createElement("div");
|
|
45
119
|
headerSection.style.backgroundColor = "#0078d4";
|
|
46
120
|
headerSection.style.padding = "20px 24px";
|
|
@@ -53,6 +127,9 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
|
|
|
53
127
|
title.style.fontWeight = "600";
|
|
54
128
|
title.style.letterSpacing = "0.3px";
|
|
55
129
|
headerSection.appendChild(title);
|
|
130
|
+
// -----------------------------------------------------------------
|
|
131
|
+
// Content wrapper
|
|
132
|
+
// -----------------------------------------------------------------
|
|
56
133
|
const contentSection = document.createElement("div");
|
|
57
134
|
contentSection.style.padding = "24px";
|
|
58
135
|
contentSection.style.backgroundColor = "#fafafa";
|
|
@@ -64,6 +141,9 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
|
|
|
64
141
|
body.style.color = "#323130";
|
|
65
142
|
body.style.lineHeight = "1.6";
|
|
66
143
|
body.style.margin = "0 0 20px 0";
|
|
144
|
+
// -----------------------------------------------------------------
|
|
145
|
+
// Sign button (footer)
|
|
146
|
+
// -----------------------------------------------------------------
|
|
67
147
|
const signButton = document.createElement("button");
|
|
68
148
|
signButton.innerText = "I Agree and Sign";
|
|
69
149
|
signButton.style.padding = "10px 24px";
|
|
@@ -75,14 +155,23 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
|
|
|
75
155
|
signButton.style.fontWeight = "600";
|
|
76
156
|
signButton.style.borderRadius = "4px";
|
|
77
157
|
signButton.style.transition = "all 0.2s ease";
|
|
158
|
+
// -----------------------------------------------------------------
|
|
159
|
+
// Two-factor verification panel
|
|
160
|
+
// -----------------------------------------------------------------
|
|
78
161
|
const verificationContainer = document.createElement("div");
|
|
162
|
+
/** Direct reference to the passcode <input> (avoids fragile querySelector). */
|
|
163
|
+
let verificationInput = null;
|
|
164
|
+
/** Direct reference to the "Resend Code" button. */
|
|
165
|
+
let sendCodeButton = null;
|
|
79
166
|
if (requireTFA) {
|
|
80
167
|
verificationContainer.style.marginBottom = "20px";
|
|
81
168
|
verificationContainer.style.padding = "16px";
|
|
82
169
|
verificationContainer.style.backgroundColor = "#fff8e1";
|
|
83
170
|
verificationContainer.style.border = "1px solid #ffd54f";
|
|
84
171
|
verificationContainer.style.borderRadius = "6px";
|
|
85
|
-
verificationContainer.style.boxShadow =
|
|
172
|
+
verificationContainer.style.boxShadow =
|
|
173
|
+
"0 2px 4px rgba(0,0,0,0.05)";
|
|
174
|
+
/* Header row with lock icon */
|
|
86
175
|
const verificationHeader = document.createElement("div");
|
|
87
176
|
verificationHeader.style.display = "flex";
|
|
88
177
|
verificationHeader.style.alignItems = "center";
|
|
@@ -106,10 +195,11 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
|
|
|
106
195
|
verificationDescription.style.color = "#605e5c";
|
|
107
196
|
verificationDescription.style.margin = "0 0 12px 0";
|
|
108
197
|
verificationDescription.style.lineHeight = "1.5";
|
|
198
|
+
/* Input + Resend row */
|
|
109
199
|
const verificationRow = document.createElement("div");
|
|
110
200
|
verificationRow.style.display = "flex";
|
|
111
201
|
verificationRow.style.gap = "10px";
|
|
112
|
-
|
|
202
|
+
verificationInput = document.createElement("input");
|
|
113
203
|
verificationInput.type = "text";
|
|
114
204
|
verificationInput.maxLength = 5;
|
|
115
205
|
verificationInput.placeholder = "Enter 5-digit code";
|
|
@@ -124,13 +214,14 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
|
|
|
124
214
|
verificationInput.style.textAlign = "center";
|
|
125
215
|
verificationInput.style.letterSpacing = "2px";
|
|
126
216
|
verificationInput.style.fontWeight = "600";
|
|
127
|
-
|
|
128
|
-
|
|
217
|
+
const vInput = verificationInput; // capture for closures
|
|
218
|
+
vInput.onfocus = () => {
|
|
219
|
+
vInput.style.borderColor = "#0078d4";
|
|
129
220
|
};
|
|
130
|
-
|
|
131
|
-
|
|
221
|
+
vInput.onblur = () => {
|
|
222
|
+
vInput.style.borderColor = "#d1d1d1";
|
|
132
223
|
};
|
|
133
|
-
|
|
224
|
+
sendCodeButton = document.createElement("button");
|
|
134
225
|
sendCodeButton.innerText = "Resend Code";
|
|
135
226
|
sendCodeButton.type = "button";
|
|
136
227
|
sendCodeButton.style.padding = "10px 16px";
|
|
@@ -143,14 +234,15 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
|
|
|
143
234
|
sendCodeButton.style.borderRadius = "4px";
|
|
144
235
|
sendCodeButton.style.fontWeight = "600";
|
|
145
236
|
sendCodeButton.style.transition = "all 0.2s ease";
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
237
|
+
const scBtn = sendCodeButton; // capture for closures
|
|
238
|
+
scBtn.onmouseover = () => {
|
|
239
|
+
scBtn.style.backgroundColor = "#0078d4";
|
|
240
|
+
scBtn.style.color = "#ffffff";
|
|
149
241
|
};
|
|
150
|
-
|
|
151
|
-
if (!
|
|
152
|
-
|
|
153
|
-
|
|
242
|
+
scBtn.onmouseout = () => {
|
|
243
|
+
if (!scBtn.disabled) {
|
|
244
|
+
scBtn.style.backgroundColor = "#ffffff";
|
|
245
|
+
scBtn.style.color = "#0078d4";
|
|
154
246
|
}
|
|
155
247
|
};
|
|
156
248
|
verificationRow.appendChild(verificationInput);
|
|
@@ -159,11 +251,17 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
|
|
|
159
251
|
verificationContainer.appendChild(verificationDescription);
|
|
160
252
|
verificationContainer.appendChild(verificationRow);
|
|
161
253
|
}
|
|
254
|
+
// -----------------------------------------------------------------
|
|
255
|
+
// Cached-signature detection
|
|
256
|
+
// -----------------------------------------------------------------
|
|
162
257
|
const cachedCompressedSig = localStorage.getItem(STORAGE_KEY);
|
|
163
|
-
|
|
164
|
-
const decompressedCachedDataUri =
|
|
258
|
+
let hasCachedSignature = cachedCompressedSig !== null && cachedCompressedSig.trim() !== "";
|
|
259
|
+
const decompressedCachedDataUri = hasCachedSignature && cachedCompressedSig
|
|
165
260
|
? lzwDecompress(cachedCompressedSig)
|
|
166
261
|
: "";
|
|
262
|
+
// -----------------------------------------------------------------
|
|
263
|
+
// Signature section wrapper
|
|
264
|
+
// -----------------------------------------------------------------
|
|
167
265
|
const signatureSection = document.createElement("div");
|
|
168
266
|
signatureSection.style.marginBottom = "20px";
|
|
169
267
|
signatureSection.style.padding = "16px";
|
|
@@ -180,12 +278,16 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
|
|
|
180
278
|
signatureLabel.style.color = "#323130";
|
|
181
279
|
signatureLabel.style.margin = "0 0 4px 0";
|
|
182
280
|
const signatureSubtext = document.createElement("p");
|
|
183
|
-
signatureSubtext.innerText =
|
|
281
|
+
signatureSubtext.innerText =
|
|
282
|
+
"Choose how you'd like to provide your signature";
|
|
184
283
|
signatureSubtext.style.fontSize = "12px";
|
|
185
284
|
signatureSubtext.style.color = "#605e5c";
|
|
186
285
|
signatureSubtext.style.margin = "0";
|
|
187
286
|
signatureHeader.appendChild(signatureLabel);
|
|
188
287
|
signatureHeader.appendChild(signatureSubtext);
|
|
288
|
+
// -----------------------------------------------------------------
|
|
289
|
+
// "Remember signature" checkbox
|
|
290
|
+
// -----------------------------------------------------------------
|
|
189
291
|
const cacheControlBox = document.createElement("div");
|
|
190
292
|
cacheControlBox.style.marginTop = "16px";
|
|
191
293
|
cacheControlBox.style.padding = "12px";
|
|
@@ -205,19 +307,25 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
|
|
|
205
307
|
cacheCheckbox.style.width = "16px";
|
|
206
308
|
cacheCheckbox.style.height = "16px";
|
|
207
309
|
const cacheCheckboxText = document.createElement("span");
|
|
208
|
-
cacheCheckboxText.innerText =
|
|
310
|
+
cacheCheckboxText.innerText =
|
|
311
|
+
"Remember my signature on this device for future transactions";
|
|
209
312
|
cacheCheckboxText.style.color = "#323130";
|
|
210
313
|
cacheCheckboxText.style.fontSize = "12px";
|
|
211
314
|
cacheCheckboxLabel.appendChild(cacheCheckbox);
|
|
212
315
|
cacheCheckboxLabel.appendChild(cacheCheckboxText);
|
|
213
316
|
cacheControlBox.appendChild(cacheCheckboxLabel);
|
|
317
|
+
// -----------------------------------------------------------------
|
|
318
|
+
// Cached-signature notice panel
|
|
319
|
+
// -----------------------------------------------------------------
|
|
214
320
|
const cachedNoticeContainer = document.createElement("div");
|
|
215
321
|
cachedNoticeContainer.style.marginBottom = "16px";
|
|
216
322
|
cachedNoticeContainer.style.padding = "16px";
|
|
217
323
|
cachedNoticeContainer.style.backgroundColor = "#e6f4ff";
|
|
218
324
|
cachedNoticeContainer.style.border = "1px solid #0078d4";
|
|
219
325
|
cachedNoticeContainer.style.borderRadius = "6px";
|
|
220
|
-
cachedNoticeContainer.style.display = hasCachedSignature
|
|
326
|
+
cachedNoticeContainer.style.display = hasCachedSignature
|
|
327
|
+
? "block"
|
|
328
|
+
: "none";
|
|
221
329
|
const cachedNoticeText = document.createElement("div");
|
|
222
330
|
cachedNoticeText.innerText = "✓ Saved signature on file";
|
|
223
331
|
cachedNoticeText.style.fontSize = "13px";
|
|
@@ -259,11 +367,21 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
|
|
|
259
367
|
cachedNoticeContainer.appendChild(cachedNoticeText);
|
|
260
368
|
cachedNoticeContainer.appendChild(cachedPreviewImage);
|
|
261
369
|
cachedNoticeContainer.appendChild(removeCachedBtn);
|
|
370
|
+
// -----------------------------------------------------------------
|
|
371
|
+
// Tab bar (Saved / Draw New / Upload Image)
|
|
372
|
+
// -----------------------------------------------------------------
|
|
262
373
|
const modeContainer = document.createElement("div");
|
|
263
374
|
modeContainer.style.display = "flex";
|
|
264
375
|
modeContainer.style.gap = "8px";
|
|
265
376
|
modeContainer.style.marginBottom = "16px";
|
|
266
377
|
modeContainer.style.borderBottom = "2px solid #edebe9";
|
|
378
|
+
/**
|
|
379
|
+
* Factory that creates a styled tab button.
|
|
380
|
+
*
|
|
381
|
+
* @param text - Label for the tab.
|
|
382
|
+
* @param isActive - Whether the tab should appear selected initially.
|
|
383
|
+
* @returns The constructed `<button>` element.
|
|
384
|
+
*/
|
|
267
385
|
const createTab = (text, isActive) => {
|
|
268
386
|
const tab = document.createElement("button");
|
|
269
387
|
tab.innerText = text;
|
|
@@ -274,23 +392,35 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
|
|
|
274
392
|
tab.style.backgroundColor = "transparent";
|
|
275
393
|
tab.style.color = isActive ? "#0078d4" : "#605e5c";
|
|
276
394
|
tab.style.border = "none";
|
|
277
|
-
tab.style.borderBottom = isActive
|
|
395
|
+
tab.style.borderBottom = isActive
|
|
396
|
+
? "3px solid #0078d4"
|
|
397
|
+
: "3px solid transparent";
|
|
278
398
|
tab.style.transition = "all 0.2s ease";
|
|
279
399
|
tab.style.outline = "none";
|
|
400
|
+
/* Hover handlers check current activeMode so they remain correct
|
|
401
|
+
after the user switches tabs. */
|
|
280
402
|
tab.onmouseover = () => {
|
|
281
|
-
|
|
403
|
+
const tabIsCurrentlyActive = (tab === cachedTabBtn && activeMode === "cached") ||
|
|
404
|
+
(tab === drawTabBtn && activeMode === "draw") ||
|
|
405
|
+
(tab === uploadTabBtn && activeMode === "upload");
|
|
406
|
+
if (!tabIsCurrentlyActive) {
|
|
282
407
|
tab.style.color = "#0078d4";
|
|
283
408
|
}
|
|
284
409
|
};
|
|
285
410
|
tab.onmouseout = () => {
|
|
286
|
-
|
|
411
|
+
const tabIsCurrentlyActive = (tab === cachedTabBtn && activeMode === "cached") ||
|
|
412
|
+
(tab === drawTabBtn && activeMode === "draw") ||
|
|
413
|
+
(tab === uploadTabBtn && activeMode === "upload");
|
|
414
|
+
if (!tabIsCurrentlyActive) {
|
|
287
415
|
tab.style.color = "#605e5c";
|
|
288
416
|
}
|
|
289
417
|
};
|
|
290
418
|
return tab;
|
|
291
419
|
};
|
|
292
420
|
const cachedTabBtn = createTab("Saved", hasCachedSignature);
|
|
293
|
-
cachedTabBtn.style.display = hasCachedSignature
|
|
421
|
+
cachedTabBtn.style.display = hasCachedSignature
|
|
422
|
+
? "inline-block"
|
|
423
|
+
: "none";
|
|
294
424
|
const drawTabBtn = createTab("Draw New", !hasCachedSignature);
|
|
295
425
|
const uploadTabBtn = createTab("Upload Image", false);
|
|
296
426
|
if (hasCachedSignature) {
|
|
@@ -298,9 +428,15 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
|
|
|
298
428
|
}
|
|
299
429
|
modeContainer.appendChild(drawTabBtn);
|
|
300
430
|
modeContainer.appendChild(uploadTabBtn);
|
|
431
|
+
// -----------------------------------------------------------------
|
|
432
|
+
// Cached panel
|
|
433
|
+
// -----------------------------------------------------------------
|
|
301
434
|
const cachedPanel = document.createElement("div");
|
|
302
435
|
cachedPanel.style.display = hasCachedSignature ? "block" : "none";
|
|
303
436
|
cachedPanel.appendChild(cachedNoticeContainer);
|
|
437
|
+
// -----------------------------------------------------------------
|
|
438
|
+
// Draw panel (canvas)
|
|
439
|
+
// -----------------------------------------------------------------
|
|
304
440
|
const drawPanel = document.createElement("div");
|
|
305
441
|
drawPanel.style.display = hasCachedSignature ? "none" : "block";
|
|
306
442
|
const canvasContainer = document.createElement("div");
|
|
@@ -324,6 +460,7 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
|
|
|
324
460
|
canvas.style.width = "100%";
|
|
325
461
|
canvas.style.borderRadius = "4px";
|
|
326
462
|
canvas.style.boxShadow = "inset 0 1px 3px rgba(0,0,0,0.1)";
|
|
463
|
+
/** Button to clear the drawing canvas. */
|
|
327
464
|
const clearButton = document.createElement("button");
|
|
328
465
|
clearButton.innerText = "Clear Canvas";
|
|
329
466
|
clearButton.style.marginTop = "10px";
|
|
@@ -346,6 +483,9 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
|
|
|
346
483
|
canvasContainer.appendChild(canvas);
|
|
347
484
|
canvasContainer.appendChild(clearButton);
|
|
348
485
|
drawPanel.appendChild(canvasContainer);
|
|
486
|
+
// -----------------------------------------------------------------
|
|
487
|
+
// Upload panel
|
|
488
|
+
// -----------------------------------------------------------------
|
|
349
489
|
const uploadPanel = document.createElement("div");
|
|
350
490
|
uploadPanel.style.display = "none";
|
|
351
491
|
uploadPanel.style.padding = "32px 24px";
|
|
@@ -355,7 +495,6 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
|
|
|
355
495
|
uploadPanel.style.borderRadius = "6px";
|
|
356
496
|
uploadPanel.style.transition = "all 0.2s ease";
|
|
357
497
|
uploadPanel.style.minHeight = "200px";
|
|
358
|
-
uploadPanel.style.display = "none";
|
|
359
498
|
uploadPanel.style.flexDirection = "column";
|
|
360
499
|
uploadPanel.style.alignItems = "center";
|
|
361
500
|
uploadPanel.style.justifyContent = "center";
|
|
@@ -396,7 +535,12 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
|
|
|
396
535
|
uploadPanel.appendChild(uploadSubtext);
|
|
397
536
|
uploadPanel.appendChild(fileInput);
|
|
398
537
|
uploadPanel.appendChild(uploadPreview);
|
|
399
|
-
|
|
538
|
+
// -----------------------------------------------------------------
|
|
539
|
+
// State
|
|
540
|
+
// -----------------------------------------------------------------
|
|
541
|
+
let activeMode = hasCachedSignature
|
|
542
|
+
? "cached"
|
|
543
|
+
: "draw";
|
|
400
544
|
let compressedUploadBase64 = "";
|
|
401
545
|
const ctx = canvas.getContext("2d");
|
|
402
546
|
let isDrawing = false;
|
|
@@ -409,19 +553,30 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
|
|
|
409
553
|
ctx.lineCap = "round";
|
|
410
554
|
ctx.lineJoin = "round";
|
|
411
555
|
}
|
|
556
|
+
// -----------------------------------------------------------------
|
|
557
|
+
// Button-state helper
|
|
558
|
+
// -----------------------------------------------------------------
|
|
559
|
+
/**
|
|
560
|
+
* Re-evaluates whether the "I Agree and Sign" button should be
|
|
561
|
+
* enabled, based on the current TFA code entry and selected
|
|
562
|
+
* signature mode.
|
|
563
|
+
*/
|
|
412
564
|
const updateButtonState = () => {
|
|
413
565
|
let isCodeValid = true;
|
|
414
|
-
if (requireTFA) {
|
|
415
|
-
const verificationInput = verificationContainer.querySelector("input");
|
|
566
|
+
if (requireTFA && verificationInput) {
|
|
416
567
|
const codeValue = verificationInput.value.trim();
|
|
417
|
-
isCodeValid =
|
|
568
|
+
isCodeValid =
|
|
569
|
+
codeValue.length === 5 && codeValue === generatedPasscode;
|
|
418
570
|
}
|
|
419
571
|
let isSignatureValid = false;
|
|
420
572
|
if (activeMode === "cached") {
|
|
421
573
|
isSignatureValid = hasCachedSignature;
|
|
422
574
|
}
|
|
423
575
|
else if (activeMode === "draw") {
|
|
424
|
-
isSignatureValid =
|
|
576
|
+
isSignatureValid =
|
|
577
|
+
hasDrawnContent &&
|
|
578
|
+
ctx !== null &&
|
|
579
|
+
!isCanvasEmpty(ctx, canvas.width, canvas.height);
|
|
425
580
|
}
|
|
426
581
|
else {
|
|
427
582
|
isSignatureValid = compressedUploadBase64.trim() !== "";
|
|
@@ -439,6 +594,15 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
|
|
|
439
594
|
signButton.style.opacity = "0.6";
|
|
440
595
|
}
|
|
441
596
|
};
|
|
597
|
+
// -----------------------------------------------------------------
|
|
598
|
+
// Tab-switching logic
|
|
599
|
+
// -----------------------------------------------------------------
|
|
600
|
+
/**
|
|
601
|
+
* Activates the given tab, hiding the other panels and updating
|
|
602
|
+
* visual tab styles.
|
|
603
|
+
*
|
|
604
|
+
* @param mode - Which panel to show.
|
|
605
|
+
*/
|
|
442
606
|
const setActiveTab = (mode) => {
|
|
443
607
|
activeMode = mode;
|
|
444
608
|
cachedPanel.style.display = mode === "cached" ? "block" : "none";
|
|
@@ -448,7 +612,11 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
|
|
|
448
612
|
btn.style.color = "#605e5c";
|
|
449
613
|
btn.style.borderBottom = "3px solid transparent";
|
|
450
614
|
});
|
|
451
|
-
const activeBtn = mode === "cached"
|
|
615
|
+
const activeBtn = mode === "cached"
|
|
616
|
+
? cachedTabBtn
|
|
617
|
+
: mode === "draw"
|
|
618
|
+
? drawTabBtn
|
|
619
|
+
: uploadTabBtn;
|
|
452
620
|
activeBtn.style.color = "#0078d4";
|
|
453
621
|
activeBtn.style.borderBottom = "3px solid #0078d4";
|
|
454
622
|
updateButtonState();
|
|
@@ -458,50 +626,63 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
|
|
|
458
626
|
}
|
|
459
627
|
drawTabBtn.onclick = () => setActiveTab("draw");
|
|
460
628
|
uploadTabBtn.onclick = () => setActiveTab("upload");
|
|
629
|
+
/** Removes the cached signature and switches to the draw tab. */
|
|
461
630
|
removeCachedBtn.onclick = () => {
|
|
462
631
|
localStorage.removeItem(STORAGE_KEY);
|
|
632
|
+
hasCachedSignature = false;
|
|
463
633
|
cachedTabBtn.style.display = "none";
|
|
464
634
|
setActiveTab("draw");
|
|
465
635
|
};
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
636
|
+
// -----------------------------------------------------------------
|
|
637
|
+
// Two-factor: send-code logic
|
|
638
|
+
// -----------------------------------------------------------------
|
|
639
|
+
if (requireTFA && context.channel && sendCodeButton && verificationInput) {
|
|
469
640
|
let cooldownTimer = null;
|
|
641
|
+
const scBtnRef = sendCodeButton;
|
|
642
|
+
const vInputRef = verificationInput;
|
|
643
|
+
/**
|
|
644
|
+
* Starts a 60-second cooldown on the "Resend Code" button to
|
|
645
|
+
* prevent rapid re-sends.
|
|
646
|
+
*/
|
|
470
647
|
const startCooldownTimer = () => {
|
|
471
648
|
let secondsLeft = 60;
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
649
|
+
scBtnRef.disabled = true;
|
|
650
|
+
scBtnRef.style.backgroundColor = "#f3f2f1";
|
|
651
|
+
scBtnRef.style.color = "#a19f9d";
|
|
652
|
+
scBtnRef.style.borderColor = "#d1d1d1";
|
|
653
|
+
scBtnRef.style.cursor = "not-allowed";
|
|
654
|
+
scBtnRef.innerText = `Resend (${secondsLeft}s)`;
|
|
478
655
|
if (cooldownTimer) {
|
|
479
656
|
clearInterval(cooldownTimer);
|
|
480
657
|
}
|
|
481
658
|
cooldownTimer = window.setInterval(() => {
|
|
482
659
|
secondsLeft -= 1;
|
|
483
660
|
if (secondsLeft > 0) {
|
|
484
|
-
|
|
661
|
+
scBtnRef.innerText = `Resend (${secondsLeft}s)`;
|
|
485
662
|
}
|
|
486
663
|
else {
|
|
487
664
|
if (cooldownTimer) {
|
|
488
665
|
clearInterval(cooldownTimer);
|
|
489
666
|
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
667
|
+
scBtnRef.disabled = false;
|
|
668
|
+
scBtnRef.style.backgroundColor = "#ffffff";
|
|
669
|
+
scBtnRef.style.color = "#0078d4";
|
|
670
|
+
scBtnRef.style.borderColor = "#0078d4";
|
|
671
|
+
scBtnRef.style.cursor = "pointer";
|
|
672
|
+
scBtnRef.innerText = "Resend Code";
|
|
496
673
|
}
|
|
497
674
|
}, 1000);
|
|
498
675
|
};
|
|
676
|
+
/**
|
|
677
|
+
* Dispatches the verification passcode through the configured
|
|
678
|
+
* channel (email / Teams) and starts the cooldown timer.
|
|
679
|
+
*/
|
|
499
680
|
const triggerSendCode = async () => {
|
|
500
|
-
|
|
501
|
-
|
|
681
|
+
scBtnRef.innerText = "Sending...";
|
|
682
|
+
scBtnRef.disabled = true;
|
|
502
683
|
try {
|
|
503
684
|
if (context.spContext && context.channel) {
|
|
504
|
-
const vResult = await (0,
|
|
685
|
+
const vResult = await (0, OtpService_1.dispatchOtpPasscode)(context.spContext, {
|
|
505
686
|
title: context.signer,
|
|
506
687
|
passcode: generatedPasscode,
|
|
507
688
|
channel: context.channel,
|
|
@@ -522,23 +703,50 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
|
|
|
522
703
|
startCooldownTimer();
|
|
523
704
|
}
|
|
524
705
|
};
|
|
525
|
-
|
|
706
|
+
scBtnRef.onclick = async () => {
|
|
526
707
|
await triggerSendCode();
|
|
527
708
|
};
|
|
528
|
-
|
|
709
|
+
vInputRef.oninput = () => {
|
|
529
710
|
updateButtonState();
|
|
530
711
|
};
|
|
531
712
|
void triggerSendCode();
|
|
532
713
|
}
|
|
714
|
+
// -----------------------------------------------------------------
|
|
715
|
+
// Canvas drawing helpers
|
|
716
|
+
// -----------------------------------------------------------------
|
|
717
|
+
/**
|
|
718
|
+
* Translates a mouse or touch event into canvas-relative
|
|
719
|
+
* coordinates, accounting for CSS scaling.
|
|
720
|
+
*
|
|
721
|
+
* @param e - The originating mouse or touch event.
|
|
722
|
+
* @returns `{ x, y }` in canvas-pixel space.
|
|
723
|
+
*/
|
|
533
724
|
const getPos = (e) => {
|
|
534
725
|
const rect = canvas.getBoundingClientRect();
|
|
535
|
-
|
|
536
|
-
|
|
726
|
+
let clientX;
|
|
727
|
+
let clientY;
|
|
728
|
+
if ("touches" in e && e.touches.length > 0) {
|
|
729
|
+
clientX = e.touches[0].clientX;
|
|
730
|
+
clientY = e.touches[0].clientY;
|
|
731
|
+
}
|
|
732
|
+
else if ("changedTouches" in e && e.changedTouches.length > 0) {
|
|
733
|
+
clientX = e.changedTouches[0].clientX;
|
|
734
|
+
clientY = e.changedTouches[0].clientY;
|
|
735
|
+
}
|
|
736
|
+
else {
|
|
737
|
+
clientX = e.clientX;
|
|
738
|
+
clientY = e.clientY;
|
|
739
|
+
}
|
|
537
740
|
return {
|
|
538
741
|
x: (clientX - rect.left) * (canvas.width / rect.width),
|
|
539
|
-
y: (clientY - rect.top) * (canvas.height / rect.height)
|
|
742
|
+
y: (clientY - rect.top) * (canvas.height / rect.height),
|
|
540
743
|
};
|
|
541
744
|
};
|
|
745
|
+
/**
|
|
746
|
+
* Begins a new drawing stroke at the pointer position.
|
|
747
|
+
*
|
|
748
|
+
* @param e - The initiating mouse/touch event.
|
|
749
|
+
*/
|
|
542
750
|
const startDraw = (e) => {
|
|
543
751
|
isDrawing = true;
|
|
544
752
|
hasDrawnContent = true;
|
|
@@ -547,6 +755,11 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
|
|
|
547
755
|
ctx === null || ctx === void 0 ? void 0 : ctx.moveTo(pos.x, pos.y);
|
|
548
756
|
e.preventDefault();
|
|
549
757
|
};
|
|
758
|
+
/**
|
|
759
|
+
* Extends the current stroke to the pointer's new position.
|
|
760
|
+
*
|
|
761
|
+
* @param e - The move event.
|
|
762
|
+
*/
|
|
550
763
|
const drawLine = (e) => {
|
|
551
764
|
if (!isDrawing || !ctx)
|
|
552
765
|
return;
|
|
@@ -556,6 +769,7 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
|
|
|
556
769
|
updateButtonState();
|
|
557
770
|
e.preventDefault();
|
|
558
771
|
};
|
|
772
|
+
/** Ends the current drawing stroke. */
|
|
559
773
|
const stopDraw = () => {
|
|
560
774
|
isDrawing = false;
|
|
561
775
|
updateButtonState();
|
|
@@ -563,9 +777,10 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
|
|
|
563
777
|
canvas.addEventListener("mousedown", startDraw);
|
|
564
778
|
canvas.addEventListener("mousemove", drawLine);
|
|
565
779
|
window.addEventListener("mouseup", stopDraw);
|
|
566
|
-
canvas.addEventListener("touchstart", startDraw);
|
|
567
|
-
canvas.addEventListener("touchmove", drawLine);
|
|
780
|
+
canvas.addEventListener("touchstart", startDraw, { passive: false });
|
|
781
|
+
canvas.addEventListener("touchmove", drawLine, { passive: false });
|
|
568
782
|
window.addEventListener("touchend", stopDraw);
|
|
783
|
+
/** Clears the drawing canvas back to a blank white rectangle. */
|
|
569
784
|
clearButton.onclick = () => {
|
|
570
785
|
if (ctx) {
|
|
571
786
|
ctx.fillStyle = "#ffffff";
|
|
@@ -574,10 +789,23 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
|
|
|
574
789
|
updateButtonState();
|
|
575
790
|
}
|
|
576
791
|
};
|
|
792
|
+
// -----------------------------------------------------------------
|
|
793
|
+
// File upload handler
|
|
794
|
+
// -----------------------------------------------------------------
|
|
795
|
+
/**
|
|
796
|
+
* Handles image file selection: validates size, resizes if needed,
|
|
797
|
+
* and stores the result as a data-URI.
|
|
798
|
+
*/
|
|
577
799
|
fileInput.onchange = (e) => {
|
|
578
800
|
const target = e.target;
|
|
579
801
|
if (target.files && target.files[0]) {
|
|
580
802
|
const file = target.files[0];
|
|
803
|
+
/* Enforce the advertised 5 MB limit. */
|
|
804
|
+
if (file.size > MAX_UPLOAD_BYTES) {
|
|
805
|
+
alert(`File size exceeds 5 MB (${(file.size / 1024 / 1024).toFixed(1)} MB). Please choose a smaller image.`);
|
|
806
|
+
target.value = "";
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
581
809
|
const reader = new FileReader();
|
|
582
810
|
reader.onload = (uploadEvent) => {
|
|
583
811
|
var _a;
|
|
@@ -604,7 +832,8 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
|
|
|
604
832
|
tCtx.fillStyle = "#ffffff";
|
|
605
833
|
tCtx.fillRect(0, 0, w, h);
|
|
606
834
|
tCtx.drawImage(img, 0, 0, w, h);
|
|
607
|
-
compressedUploadBase64 =
|
|
835
|
+
compressedUploadBase64 =
|
|
836
|
+
tempCanvas.toDataURL("image/png");
|
|
608
837
|
uploadPreview.src = compressedUploadBase64;
|
|
609
838
|
uploadPreview.style.display = "block";
|
|
610
839
|
updateButtonState();
|
|
@@ -616,6 +845,9 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
|
|
|
616
845
|
reader.readAsDataURL(file);
|
|
617
846
|
}
|
|
618
847
|
};
|
|
848
|
+
// -----------------------------------------------------------------
|
|
849
|
+
// Footer (Cancel + Sign buttons)
|
|
850
|
+
// -----------------------------------------------------------------
|
|
619
851
|
const footerSection = document.createElement("div");
|
|
620
852
|
footerSection.style.padding = "20px 24px";
|
|
621
853
|
footerSection.style.backgroundColor = "#ffffff";
|
|
@@ -650,16 +882,33 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
|
|
|
650
882
|
signButton.style.backgroundColor = "#0078d4";
|
|
651
883
|
}
|
|
652
884
|
};
|
|
885
|
+
// -----------------------------------------------------------------
|
|
886
|
+
// Cleanup helper
|
|
887
|
+
// -----------------------------------------------------------------
|
|
888
|
+
/**
|
|
889
|
+
* Removes the modal overlay from the DOM and unregisters window-
|
|
890
|
+
* level event listeners to prevent memory leaks.
|
|
891
|
+
*/
|
|
653
892
|
const cleanup = () => {
|
|
893
|
+
window.removeEventListener("mouseup", stopDraw);
|
|
894
|
+
window.removeEventListener("touchend", stopDraw);
|
|
654
895
|
document.body.removeChild(overlay);
|
|
655
896
|
};
|
|
897
|
+
/** Cancel button dismisses the dialog and resolves with `undefined`. */
|
|
656
898
|
cancelButton.onclick = () => {
|
|
657
899
|
cleanup();
|
|
658
900
|
resolve(undefined);
|
|
659
901
|
};
|
|
902
|
+
// -----------------------------------------------------------------
|
|
903
|
+
// Sign button handler
|
|
904
|
+
// -----------------------------------------------------------------
|
|
905
|
+
/**
|
|
906
|
+
* Validates the TFA code (if required), extracts the signature
|
|
907
|
+
* data from the active panel, compresses it, optionally caches it,
|
|
908
|
+
* then generates the SHA-256 audit record.
|
|
909
|
+
*/
|
|
660
910
|
signButton.onclick = async () => {
|
|
661
|
-
if (requireTFA) {
|
|
662
|
-
const verificationInput = verificationContainer.querySelector("input");
|
|
911
|
+
if (requireTFA && verificationInput) {
|
|
663
912
|
const enteredCode = verificationInput.value.trim();
|
|
664
913
|
if (enteredCode !== generatedPasscode) {
|
|
665
914
|
alert("Invalid verification code. Please enter the correct 5-digit code.");
|
|
@@ -675,14 +924,16 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
|
|
|
675
924
|
finalDataUrl = lzwDecompress(cachedCompressedSig);
|
|
676
925
|
}
|
|
677
926
|
else if (activeMode === "draw") {
|
|
678
|
-
if (!ctx ||
|
|
927
|
+
if (!ctx ||
|
|
928
|
+
isCanvasEmpty(ctx, canvas.width, canvas.height)) {
|
|
679
929
|
alert("Please draw your signature before proceeding.");
|
|
680
930
|
return;
|
|
681
931
|
}
|
|
682
932
|
finalDataUrl = downscaleCanvas(canvas, 560, 160);
|
|
683
933
|
}
|
|
684
934
|
else {
|
|
685
|
-
if (!compressedUploadBase64 ||
|
|
935
|
+
if (!compressedUploadBase64 ||
|
|
936
|
+
compressedUploadBase64.trim() === "") {
|
|
686
937
|
alert("Please upload a signature image before proceeding.");
|
|
687
938
|
return;
|
|
688
939
|
}
|
|
@@ -699,7 +950,7 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
|
|
|
699
950
|
try {
|
|
700
951
|
const fullContext = Object.assign(Object.assign({}, context), { signatureData: compressedString });
|
|
701
952
|
const result = await generateSecureAuditRecordInternal(fullContext);
|
|
702
|
-
if (storedVerificationItemId) {
|
|
953
|
+
if (storedVerificationItemId !== undefined) {
|
|
703
954
|
result.verificationItemId = storedVerificationItemId;
|
|
704
955
|
}
|
|
705
956
|
resolve(result);
|
|
@@ -710,6 +961,9 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
|
|
|
710
961
|
}
|
|
711
962
|
};
|
|
712
963
|
updateButtonState();
|
|
964
|
+
// -----------------------------------------------------------------
|
|
965
|
+
// Assemble DOM tree
|
|
966
|
+
// -----------------------------------------------------------------
|
|
713
967
|
footerSection.appendChild(cancelButton);
|
|
714
968
|
footerSection.appendChild(signButton);
|
|
715
969
|
contentSection.appendChild(body);
|
|
@@ -731,6 +985,20 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
|
|
|
731
985
|
});
|
|
732
986
|
}
|
|
733
987
|
exports.promptAndGenerateSecureAudit = promptAndGenerateSecureAudit;
|
|
988
|
+
// ---------------------------------------------------------------------------
|
|
989
|
+
// Signature decompression for reports
|
|
990
|
+
// ---------------------------------------------------------------------------
|
|
991
|
+
/**
|
|
992
|
+
* Decompresses an LZW-compressed signature string back into its
|
|
993
|
+
* original `data:image/…` data-URI for display in reports or
|
|
994
|
+
* print views.
|
|
995
|
+
*
|
|
996
|
+
* @param compressedSignatureData - The LZW-compressed string stored
|
|
997
|
+
* in SharePoint.
|
|
998
|
+
* @returns The original data-URI, or an empty string if
|
|
999
|
+
* decompression fails or the result is not an image
|
|
1000
|
+
* data-URI.
|
|
1001
|
+
*/
|
|
734
1002
|
function getReportableSignature(compressedSignatureData) {
|
|
735
1003
|
const decompressed = lzwDecompress(compressedSignatureData);
|
|
736
1004
|
if (!decompressed || !decompressed.startsWith("data:image")) {
|
|
@@ -739,6 +1007,22 @@ function getReportableSignature(compressedSignatureData) {
|
|
|
739
1007
|
return decompressed;
|
|
740
1008
|
}
|
|
741
1009
|
exports.getReportableSignature = getReportableSignature;
|
|
1010
|
+
// ---------------------------------------------------------------------------
|
|
1011
|
+
// LZW compression / decompression
|
|
1012
|
+
// ---------------------------------------------------------------------------
|
|
1013
|
+
/**
|
|
1014
|
+
* Compresses a string using the LZW (Lempel-Ziv-Welch) algorithm.
|
|
1015
|
+
*
|
|
1016
|
+
* **Caveat:** When the dictionary grows past 65 535 entries,
|
|
1017
|
+
* `String.fromCharCode` will produce values that may not survive
|
|
1018
|
+
* a round-trip through `localStorage` or JSON. For very large
|
|
1019
|
+
* inputs consider chunking or switching to a Uint16Array-backed
|
|
1020
|
+
* encoding.
|
|
1021
|
+
*
|
|
1022
|
+
* @param input - The raw string to compress.
|
|
1023
|
+
* @returns The compressed string (each character encodes one
|
|
1024
|
+
* dictionary index).
|
|
1025
|
+
*/
|
|
742
1026
|
function lzwCompress(input) {
|
|
743
1027
|
const dictionary = {};
|
|
744
1028
|
let c = "";
|
|
@@ -764,8 +1048,17 @@ function lzwCompress(input) {
|
|
|
764
1048
|
if (w !== "") {
|
|
765
1049
|
result.push(dictionary[w]);
|
|
766
1050
|
}
|
|
767
|
-
return result
|
|
1051
|
+
return result
|
|
1052
|
+
.map((n) => String.fromCharCode(n))
|
|
1053
|
+
.join("");
|
|
768
1054
|
}
|
|
1055
|
+
/**
|
|
1056
|
+
* Decompresses a string that was compressed with {@link lzwCompress}.
|
|
1057
|
+
*
|
|
1058
|
+
* @param compressed - The LZW-compressed string.
|
|
1059
|
+
* @returns The original string, or an empty string if the input is
|
|
1060
|
+
* empty or contains an unrecognised dictionary reference.
|
|
1061
|
+
*/
|
|
769
1062
|
function lzwDecompress(compressed) {
|
|
770
1063
|
const dictionary = {};
|
|
771
1064
|
const result = [];
|
|
@@ -780,13 +1073,14 @@ function lzwDecompress(compressed) {
|
|
|
780
1073
|
let entry = "";
|
|
781
1074
|
for (let i = 1; i < compressed.length; i += 1) {
|
|
782
1075
|
const k = compressed.charCodeAt(i);
|
|
783
|
-
if (dictionary[k]) {
|
|
1076
|
+
if (dictionary[k] !== undefined) {
|
|
784
1077
|
entry = dictionary[k];
|
|
785
1078
|
}
|
|
786
1079
|
else if (k === dictionarySize) {
|
|
787
1080
|
entry = w + w.charAt(0);
|
|
788
1081
|
}
|
|
789
1082
|
else {
|
|
1083
|
+
console.warn(`lzwDecompress: unrecognised dictionary index ${k} at position ${i}.`);
|
|
790
1084
|
return "";
|
|
791
1085
|
}
|
|
792
1086
|
result.push(entry);
|
|
@@ -795,6 +1089,18 @@ function lzwDecompress(compressed) {
|
|
|
795
1089
|
}
|
|
796
1090
|
return result.join("");
|
|
797
1091
|
}
|
|
1092
|
+
// ---------------------------------------------------------------------------
|
|
1093
|
+
// Canvas utilities
|
|
1094
|
+
// ---------------------------------------------------------------------------
|
|
1095
|
+
/**
|
|
1096
|
+
* Draws the contents of `sourceCanvas` onto a new canvas at the
|
|
1097
|
+
* specified dimensions and returns the result as a PNG data-URI.
|
|
1098
|
+
*
|
|
1099
|
+
* @param sourceCanvas - The canvas element to read from.
|
|
1100
|
+
* @param targetWidth - Desired output width in pixels.
|
|
1101
|
+
* @param targetHeight - Desired output height in pixels.
|
|
1102
|
+
* @returns A `data:image/png;base64,…` string.
|
|
1103
|
+
*/
|
|
798
1104
|
function downscaleCanvas(sourceCanvas, targetWidth, targetHeight) {
|
|
799
1105
|
const tempCanvas = document.createElement("canvas");
|
|
800
1106
|
tempCanvas.width = targetWidth;
|
|
@@ -807,6 +1113,16 @@ function downscaleCanvas(sourceCanvas, targetWidth, targetHeight) {
|
|
|
807
1113
|
}
|
|
808
1114
|
return tempCanvas.toDataURL("image/png");
|
|
809
1115
|
}
|
|
1116
|
+
/**
|
|
1117
|
+
* Determines whether every pixel on the canvas is pure white
|
|
1118
|
+
* (`rgb(255, 255, 255)`), which indicates that the user has not
|
|
1119
|
+
* drawn anything yet.
|
|
1120
|
+
*
|
|
1121
|
+
* @param ctx - The 2D rendering context of the canvas.
|
|
1122
|
+
* @param width - Canvas width in pixels.
|
|
1123
|
+
* @param height - Canvas height in pixels.
|
|
1124
|
+
* @returns `true` if the canvas contains only white pixels.
|
|
1125
|
+
*/
|
|
810
1126
|
function isCanvasEmpty(ctx, width, height) {
|
|
811
1127
|
const pixelBuffer = ctx.getImageData(0, 0, width, height).data;
|
|
812
1128
|
for (let i = 0; i < pixelBuffer.length; i += 4) {
|
|
@@ -818,7 +1134,31 @@ function isCanvasEmpty(ctx, width, height) {
|
|
|
818
1134
|
}
|
|
819
1135
|
return true;
|
|
820
1136
|
}
|
|
821
|
-
|
|
1137
|
+
// ---------------------------------------------------------------------------
|
|
1138
|
+
// Audit-record verification
|
|
1139
|
+
// ---------------------------------------------------------------------------
|
|
1140
|
+
/**
|
|
1141
|
+
* Re-computes the SHA-256 hash of the audit envelope constructed from
|
|
1142
|
+
* the supplied parameters and compares it to `storedHash`.
|
|
1143
|
+
*
|
|
1144
|
+
* This allows any consumer to independently verify that a signed
|
|
1145
|
+
* record has not been tampered with, without needing access to the
|
|
1146
|
+
* original signature image.
|
|
1147
|
+
*
|
|
1148
|
+
* **Note:** Only top-level payload keys are sorted. If your payload
|
|
1149
|
+
* contains nested objects whose key order may vary, consider using a
|
|
1150
|
+
* deep-sort utility before calling this function.
|
|
1151
|
+
*
|
|
1152
|
+
* @param payloadToVerify - The payload that was originally signed.
|
|
1153
|
+
* @param signer - The signer identifier used at sign time.
|
|
1154
|
+
* @param timestamp - The ISO-8601 timestamp captured at sign
|
|
1155
|
+
* time.
|
|
1156
|
+
* @param _compressedSignatureData - The compressed signature (unused by the
|
|
1157
|
+
* hash, retained for API symmetry).
|
|
1158
|
+
* @param storedHash - The SHA-256 hex digest to compare against.
|
|
1159
|
+
* @returns `true` if the recomputed hash matches `storedHash`.
|
|
1160
|
+
*/
|
|
1161
|
+
async function verifySecureAuditRecord(payloadToVerify, signer, timestamp, _compressedSignatureData, storedHash) {
|
|
822
1162
|
try {
|
|
823
1163
|
if (!payloadToVerify || !storedHash)
|
|
824
1164
|
return false;
|
|
@@ -831,7 +1171,8 @@ async function verifySecureAuditRecord(payloadToVerify, signer, timestamp, compr
|
|
|
831
1171
|
const auditEnvelopeJson = JSON.stringify(auditEnvelope);
|
|
832
1172
|
const encoder = new TextEncoder();
|
|
833
1173
|
const encodedBytes = encoder.encode(auditEnvelopeJson);
|
|
834
|
-
|
|
1174
|
+
// Fixed BufferSource type incompatibility:
|
|
1175
|
+
const hashBuffer = await window.crypto.subtle.digest("SHA-256", encodedBytes.buffer);
|
|
835
1176
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
836
1177
|
const recomputedHash = hashArray
|
|
837
1178
|
.map((b) => b.toString(16).padStart(2, "0"))
|
|
@@ -843,29 +1184,50 @@ async function verifySecureAuditRecord(payloadToVerify, signer, timestamp, compr
|
|
|
843
1184
|
}
|
|
844
1185
|
}
|
|
845
1186
|
exports.verifySecureAuditRecord = verifySecureAuditRecord;
|
|
1187
|
+
// ---------------------------------------------------------------------------
|
|
1188
|
+
// Internal – audit-record generation
|
|
1189
|
+
// ---------------------------------------------------------------------------
|
|
1190
|
+
/**
|
|
1191
|
+
* Constructs the canonical audit envelope from the signer context,
|
|
1192
|
+
* hashes it with SHA-256, and returns a `SharePointAuditRecord`
|
|
1193
|
+
* ready for persistence.
|
|
1194
|
+
*
|
|
1195
|
+
* This is an internal function and is not exported.
|
|
1196
|
+
*
|
|
1197
|
+
* @param context - The full signer context, extended with the
|
|
1198
|
+
* compressed signature data string.
|
|
1199
|
+
* @returns A fully populated `SharePointAuditRecord`.
|
|
1200
|
+
*
|
|
1201
|
+
* @throws {Error} If the payload is empty or the signer is blank.
|
|
1202
|
+
*/
|
|
846
1203
|
async function generateSecureAuditRecordInternal(context) {
|
|
847
1204
|
const { payload, signer, signatureData } = context;
|
|
848
|
-
if (!payload || Object.keys(payload).length === 0)
|
|
1205
|
+
if (!payload || Object.keys(payload).length === 0) {
|
|
849
1206
|
throw new Error("Payload cannot be empty.");
|
|
850
|
-
|
|
1207
|
+
}
|
|
1208
|
+
if (!signer || signer.trim() === "") {
|
|
851
1209
|
throw new Error("Signer identifier is required for sealing.");
|
|
1210
|
+
}
|
|
852
1211
|
const signatureTimestamp = new Date().toISOString();
|
|
853
1212
|
const sortedPayloadString = JSON.stringify(payload, Object.keys(payload).sort());
|
|
854
1213
|
const auditEnvelope = {
|
|
855
1214
|
payload: JSON.parse(sortedPayloadString),
|
|
856
1215
|
signer: signer.toLowerCase().trim(),
|
|
857
|
-
timestamp: signatureTimestamp.trim()
|
|
1216
|
+
timestamp: signatureTimestamp.trim(),
|
|
858
1217
|
};
|
|
859
1218
|
const auditEnvelopeJson = JSON.stringify(auditEnvelope);
|
|
860
1219
|
const encoder = new TextEncoder();
|
|
861
1220
|
const encodedBytes = encoder.encode(auditEnvelopeJson);
|
|
862
|
-
|
|
1221
|
+
// Passing `encodedBytes.buffer as ArrayBuffer` satisfies the DOM BufferSource definition
|
|
1222
|
+
const hashBuffer = await window.crypto.subtle.digest("SHA-256", encodedBytes.buffer);
|
|
863
1223
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
864
|
-
const signatureHash = hashArray
|
|
1224
|
+
const signatureHash = hashArray
|
|
1225
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
1226
|
+
.join("");
|
|
865
1227
|
return {
|
|
866
1228
|
signatureHash,
|
|
867
1229
|
signatureData,
|
|
868
1230
|
signatureTimestamp,
|
|
869
|
-
verificationItemId: context.itemID
|
|
1231
|
+
verificationItemId: context.itemID,
|
|
870
1232
|
};
|
|
871
1233
|
}
|