lpdf-sig.lmt /size: 14 Kb    last modification: 2024-01-16 10:22
1if not modules then modules = { } end modules ['lpdf-sig'] = {
2    version   = 1.001,
3    optimize  = true,
4    comment   = "companion to back-imp-pdf.mkxl",
5    author    = "Hans Hagen, PRAGMA-ADE, Hasselt NL",
6    copyright = "PRAGMA ADE / ConTeXt Development Team",
7    license   = "see context related readme files"
8}
9
10-- experiment and playground
11
12local find, gsub, rep, format = string.find, string.gsub, string.rep, string.format
13
14-- M         = "D:20231118193724Z",
15-- Reference = pdfdictionary  {
16--     Data            = catalog,
17--     TransformMethod = pdfconstant("FieldMDP"),
18--     Type            = pdfconstant("SigRef"),
19--     TransformParams = pdfdictionary {
20--         Action = pdfconstant("Include"),
21--         Fields = pdfarray(),
22--         Type   = pdfconstant("TransformParams"),
23--         V      = 1.2,
24--     }
25-- },
26
27local function updatesignature(signature,...)
28    local updated = gsub(signature,
29     -- "/ByteRange %[ 2000000000 2000000000 2000000000 2000000000 %]",
30        "/ByteRange %[ .......... .......... .......... .......... %]",
31        format("/ByteRange [ %010i %010i %010i %010i ]",...)
32    )
33    return updated ~= signature and updated or false
34end
35
36local function getbyteranges(signature,position,filesize)
37    local leftangle  = find(signature,"<0")
38    local rightangle = find(signature,"0>")
39    local b1 = 0
40    local n1 = position + leftangle  - 2
41    local b2 = position + rightangle + 1
42    local n2 = filesize - b2
43    return { b1, n1, b2, n2 }
44end
45
46if lpdf and context then
47
48    local pdfreserveobject = lpdf.reserveobject
49    local pdfflushobject   = lpdf.flushobject
50    local pdfreference     = lpdf.reference
51    local pdfconstant      = lpdf.constant
52    local pdfdictionary    = lpdf.dictionary
53    local pdfarray         = lpdf.array
54    local pdfliteral       = lpdf.literal
55    local pdfunicode       = lpdf.unicode
56
57    local status    = {
58        signature = false,
59        position  = false,
60        filename  = false,
61        filesize  = false,
62    }
63
64    local reference = nil
65
66    function lpdf.registersignature(value)
67        if value == "pkcs7" then
68            local signature = pdfreserveobject()
69            status.signature = signature
70         -- reference = pdfreference(pdfflushobject(pdfdictionary {
71         --     DigestMethod = pdfconstant("SHA256"),
72         -- }))
73            return pdfreference(signature)
74        end
75    end
76
77    function lpdf.preparesignature(flush,f,offset,objects)
78        local signature = status.signature
79        if signature then
80            local d = pdfdictionary {
81                ByteRange    = pdfarray { 2000000000, 2000000000, 2000000000, 2000000000 },
82                Contents     = pdfliteral(rep("0",4096),true), -- should be enough
83                Filter       = pdfconstant("Adobe.PPKLite"),
84                SubFilter    = pdfconstant("adbe.pkcs7.detached"),
85                Type         = pdfconstant("Sig"),
86             -- Reasons      = pdfunicode("just to be sure"),
87                Reference    = reference,
88            }
89            objects[signature] = offset
90            local object = signature .. " 0 obj\n" .. tostring(d) .. "\nendobj\n"
91            status.reference = signature
92            status.position  = offset
93            status.signature = object
94            flush(f,object)
95            offset = offset + #object
96        end
97        return offset
98    end
99
100    function lpdf.finalizesignature(filename,usedsize)
101        if not filename then
102            return
103        end
104        local signature = status.signature
105        if not signature then
106            return
107        end
108        local signame    = file.replacesuffix(filename,"sig")
109        local filesize   = file.size(filename)
110        local samesize   = usedsize ~= filesize
111        local handle     = samesize and io.open(filename,"r+b")
112        local position   = status.position
113        status.ranges    = getbyteranges(signature,position,filesize)
114        status.signature = signature
115        status.length    = #signature
116        status.filename  = filename
117        status.filesize  = filesize
118        table.save(signame, status)
119        if handle then
120            local updated = updatesignature(signature,b1,n1,b2,n2)
121            if updated then
122                handle:seek("set",position)
123                handle:write(updated)
124                handle:close()
125                status.updated = true
126            end
127        end
128    end
129
130else
131
132    -- "openssl cms -verify -noverify -cmsout -print -inform DER -in ".. binfile
133    -- -binary is needed, otherwise \nopdfcompression output will be real slow
134
135          lpdf    = lpdf or { }
136    local report  = logs.reporter("sign pdf")
137
138    local openssl = nil
139
140    local runner = sandbox and sandbox.registerrunner {
141        name     = "openssl",
142        program  = "openssl",
143        template = "cms -sign -binary -passin pass:%password% -in %datafile% -out %signature% -outform der -signer %certificate%",
144        reporter = report,
145    }
146
147    local function checklibrary(uselibrary)
148        if uselibrary and openssl == nil then
149            dofile(resolvers.findfile("libs-imp-openssl.lmt","tex"))
150            openssl = require("openssl")
151            if not openssl then
152                openssl = false
153            end
154        end
155        return openssl
156    end
157
158    -- Here we use the status file so that we don't need to load the pdf file
159    -- which saves time and memory.
160
161    local function getsigdata(pdffile,f)
162        if not lpdf.epdf then
163            report("epdf support is not loaded")
164            return
165        end
166        local pdf = lpdf.epdf.load(pdffile)
167        if not pdf then
168            report("file %a can't be loaded",pdffile)
169            return
170        end
171        local widgets = pdf.widgets
172        if not widgets then
173            lpdf.close(pdf)
174            return
175        end
176        local position = nil
177        local length   = nil
178        for i=1,#widgets do
179            local annotation = widgets[i]
180            local parent = annotation.Parent or { }
181            local what   = annotation.FT or parent.FT
182            if what == "Sig" then
183                local obj = annotation.__raw__.V
184                if obj and obj[1]== 10 then
185                    position, length = lpdf.epdf.objectrange(pdf,obj[3])
186                    break
187                end
188            end
189        end
190        lpdf.close(pdf)
191        if position then
192            local p = f:seek()
193            f:seek("set",position)
194            local signature = f:read(length),
195            f:seek("set",p)
196            local filesize = file.size(pdffile)
197            return {
198                position  = position,
199                length    = length,
200                signature = signature,
201                filename  = pdffile,
202                filesize  = filesize,
203                ranges    = getbyteranges(signature,position,filesize)
204            }
205        end
206    end
207
208    function lpdf.sign(specification)
209        local report      = specification.report      or report
210        local pdffile     = specification.filename    or ""
211        local certificate = specification.certificate or ""
212        local password    = specification.password    or ""
213        if pdffile == "" or certificate == "" or password == "" then
214            report("invalid specification")
215            return
216        end
217        --
218        local openssl = checklibrary(specification.uselibrary or false)
219        --
220        local sigfile = file.replacesuffix(pdffile,"sig")
221        local tmpfile = file.replacesuffix(pdffile,"tmp")
222        local binfile = file.replacesuffix(pdffile,"bin")
223        --
224        local f = io.open(pdffile,"r+b")
225        if not f then
226            report("unable to open %a for reading and writing",pdffile)
227            return
228        end
229        --
230        local data = table.load(sigfile)
231        if not data then
232            data = getsigdata(pdffile,f)
233        end
234        if not data then
235            report("unable to open %a",sigfile)
236            return
237        end
238        local filename  = data.filename
239        if filename ~= pdffile then
240            report("file mismatch %a",pdffile)
241            return
242        end
243        --
244        local signature = data.signature
245        local position  = data.position
246        local ranges    = data.ranges
247        local filesize  = data.filesize
248        statistics.starttiming("sign pdf")
249        local before = f:read(position)
250        local insert = f:seek()
251        local middle = f:read(#signature)
252        local after  = f:read(filesize) -- no problem if we overshoot
253        if not before or not after then
254            report("invalid status")
255            return
256        end
257        if middle ~= signature then
258            report("no prestine signature")
259            return
260        end
261        --
262     -- if not data.updated then
263            signature = updatesignature(signature,unpack(ranges)) or signature
264     -- end
265        --
266        middle = string.gsub(signature," <0.-0> ","  ")
267        local data = before .. middle .. after -- we can nil before and after
268        statistics.starttiming("sign ssh")
269        report("signing %a",pdffile)
270        local digest = nil
271        if openssl then
272            local result, message = openssl.sign {
273                data     = data,
274                certfile = certificate,
275                password = password,
276            }
277            if result then
278                digest = message
279            else
280                report("ssh error: %a",message)
281            end
282        else
283            io.savedata(tmpfile,data)
284            runner {
285                password    = password,
286                datafile    = tmpfile,
287                signature   = binfile,
288                certificate = certificate,
289            }
290            statistics.stoptiming("sign ssh")
291            digest = io.loaddata(binfile)
292            if not digest or digest == "" then
293                report("invalid digest from ssh")
294                return
295            end
296            if status.purge then
297                os.remove(tmpfile)
298                os.remove(binfile)
299            end
300        end
301        if digest then
302            digest = string.tohex(digest)
303            signature = string.gsub(signature,
304                "(/Contents <)([^>]+)(>)",
305                function(b,s,e) return b .. digest .. string.rep("0",#s-#digest) .. e end
306            )
307            f:seek("set",insert)
308            f:write(signature)
309            f:close()
310        end
311        if status.purge then
312            os.remove(sigfile)
313        end
314        statistics.stoptiming("sign pdf")
315        status.sshtime = statistics.elapsedtime("sign ssh")
316        status.pdftime = statistics.elapsedtime("sign pdf")
317        report("filesize %i bytes, ssh time %0.3f seconds, total time %0.3f seconds",
318            filesize,
319            statistics.elapsedtime("sign ssh"),
320            statistics.elapsedtime("sign pdf")
321        )
322        return status
323    end
324
325 -- local runner = sandbox and sandbox.registerrunner {
326 --     name     = "openssl",
327 --     program  = "openssl",
328 --     template = "...", -- signature and datafile
329 --     reporter = report,
330 -- }
331
332    -- Here we load the to be checked pdf file because the status file is not present
333    -- and the pdf file can be diferent anyway.
334
335    function lpdf.verify(specification)
336        if not lpdf.epdf then
337            report("epdf support is not loaded")
338            return
339        end
340        --
341        local report      = specification.report or report
342        local pdffile     = specification.filename or ""
343        local certificate = specification.certificate or ""
344        local password    = specification.password or ""
345        if pdffile == "" or certificate == "" or password == "" then
346            report("invalid specification")
347            return
348        end
349        --
350        local openssl = checklibrary(specification.uselibrary or false)
351        --
352        local pdf = lpdf.epdf.load(pdffile)
353        if not pdf then
354            report("file %a can't be loaded",pdffile)
355            return
356        end
357        --
358        local widgets = pdf.widgets
359        if not widgets then
360            report("file %a has no signature widgets",pdffile)
361            lpdf.close(pdf)
362            return
363        end
364        local signature = nil
365        local byterange = nil
366        for i=1,#widgets do
367            local annotation = widgets[i]
368            local parent = annotation.Parent or { }
369            local what   = annotation.FT or parent.FT
370            if what == "Sig" then
371                local value = annotation.V
372                if value then
373                    signature = string.tobytes(tostring(value.Contents) or "")
374                    byterange = value.ByteRange
375                end
376            end
377        end
378        lpdf.close(pdf)
379        if not signature or signature == "" or not byterange then
380            report("file %a has no signature",pdffile)
381            return
382        end
383        --
384        local f = io.open(pdffile,"rb")
385        if not f then
386            report("unable to open %a for reading",pdffile)
387            return
388        end
389        f:seek("set",byterange[1])
390        local before = f:read(byterange[2] + 1)
391     -- f:seek("set",byterange[2] + 2)
392     -- local existing = f:read(byterange[3] - byterange[2] - 3)
393        f:seek("set",byterange[3])
394        local after = f:read(byterange[4])
395        -- we could check the size
396        if not before or not after then
397            report("invalid byteranges in %a",pdffile)
398            return
399        end
400        local okay = false
401        local data = before .. after
402        if openssl then
403            okay = openssl.verify {
404                data      = data,
405                certfile  = certificate,
406                password  = password,
407                signature = signature,
408            }
409        else
410            report("verifying with the binary is not yet implemented")
411         -- local tmpfile = file.replacesuffix(pdffile,"tmp")
412         -- local binfile = file.replacesuffix(pdffile,"bin")
413         --
414         -- io.savedata(binfile,signature)
415         -- io.savedata(tmpfile,data)
416         -- runner {
417         --     datafile  = tmpfile,
418         --     signature = binfile,
419         -- }
420         -- os.remove(tmpfile)
421         -- os.remove(binfile)
422        end
423        if okay then
424            report("signature in file %a matches the content",pdffile)
425        else
426            report("signature in file %a doesn't match the content",pdffile)
427        end
428    end
429
430end
431