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