easy_code_sign 0.1.0 → 0.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +18 -19
- data/lib/easy_code_sign/pdf/cms_builder.rb +142 -0
- data/lib/easy_code_sign/pdf/native_signer.rb +275 -0
- data/lib/easy_code_sign/signable/pdf_file.rb +54 -424
- data/lib/easy_code_sign/version.rb +1 -1
- data/lib/easy_code_sign.rb +0 -2
- data/test/pdf_signable_test.rb +87 -463
- metadata +6 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 30b0230f9ff02c468a22aaae42998b4d7917138487fb9e650d00d76e424e61ac
|
|
4
|
+
data.tar.gz: 1f7ab49d94de297346fbc4ad929fffe05ad9d5b6b928dfbebb3afe987c2707ca
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '00188cddd0c07ce157367eeea03d89dcbec4a527395be854ca5713b7b0d293babd645c1c458514d839f82fba7f1a51e379f975dc1627027791266e7f37c00c90'
|
|
7
|
+
data.tar.gz: a0f322b5af65a1f298f32725098a5f177b269b3064c2ae66d4f8556cc5ff7df934386cf5bec8b9050c8f64f3d12a7735b9537d7d1d4cba02a356cd31b1d8a4b2
|
data/CHANGELOG.md
CHANGED
|
@@ -7,31 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2026-03-01
|
|
11
|
+
|
|
10
12
|
### Added
|
|
11
13
|
|
|
12
|
-
- **PDF
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
|
|
16
|
-
-
|
|
17
|
-
|
|
18
|
-
- ByteRange-based signing for PDF incremental updates
|
|
14
|
+
- **Native MIT-licensed PDF signing backend** (`NativeSigner` + `CmsBuilder`)
|
|
15
|
+
- ISO 32000 incremental-update signatures (`adbe.pkcs7.detached`) with no AGPL dependency
|
|
16
|
+
- `CmsBuilder`: assembles CMS `SignedData` via OpenSSL ASN.1 with a `sign_bytes(hash)` callback
|
|
17
|
+
interface — compatible with HSM/hardware-token providers that only expose raw signing
|
|
18
|
+
- `NativeSigner`: appends signature dict, AcroForm field, and updated catalog as a valid
|
|
19
|
+
PDF incremental update; computes ByteRange via raw file I/O
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
- Verify signed PDF documents
|
|
22
|
-
- ByteRange integrity checking
|
|
23
|
-
- Extract and display PDF signature metadata
|
|
21
|
+
### Changed
|
|
24
22
|
|
|
25
|
-
-
|
|
26
|
-
|
|
27
|
-
- `--signature-page` - Select page for signature
|
|
28
|
-
- `--signature-position` - Position preset (top_left, top_right, bottom_left, bottom_right)
|
|
29
|
-
- `--signature-reason` - Reason for signing
|
|
30
|
-
- `--signature-location` - Signing location
|
|
23
|
+
- `pdf-reader` (~> 2.0, MIT) replaces `hexapdf` as the runtime dependency for PDF parsing
|
|
24
|
+
- `PdfFile#apply_signature` and `#extract_signature` use the native backend by default
|
|
31
25
|
|
|
32
|
-
###
|
|
26
|
+
### Removed
|
|
33
27
|
|
|
34
|
-
-
|
|
28
|
+
- **HexaPDF dependency** (AGPL) — removed from both runtime and development dependencies.
|
|
29
|
+
easy_code_sign is now fully MIT-licensed throughout its dependency chain.
|
|
30
|
+
- Deferred signing API (`prepare_deferred`, `finalize_deferred`) — these were built on
|
|
31
|
+
HexaPDF internals and are removed with it.
|
|
35
32
|
|
|
36
33
|
## [0.1.0] - 2025-01-06
|
|
37
34
|
|
|
@@ -92,4 +89,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
92
89
|
- Private keys never leave the hardware token
|
|
93
90
|
- Certificate revocation checking enabled by default
|
|
94
91
|
|
|
92
|
+
[Unreleased]: https://github.com/mpantel/easy_code_sign/compare/v0.2.0...HEAD
|
|
93
|
+
[0.2.0]: https://github.com/mpantel/easy_code_sign/compare/v0.1.0...v0.2.0
|
|
95
94
|
[0.1.0]: https://github.com/mpantel/easy_code_sign/releases/tag/v0.1.0
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module EasyCodeSign
|
|
6
|
+
module Pdf
|
|
7
|
+
# Builds a detached CMS SignedData structure for PDF signing (adbe.pkcs7.detached).
|
|
8
|
+
#
|
|
9
|
+
# This is a pure-OpenSSL/MIT implementation that does not require HexaPDF.
|
|
10
|
+
# It uses OpenSSL ASN.1 primitives to assemble the CMS structure and supports
|
|
11
|
+
# callback-based signing (HSM, hardware token) via a sign_bytes(hash) block.
|
|
12
|
+
#
|
|
13
|
+
# The block receives SHA256(signed_attrs_DER) and must return raw RSA signature bytes.
|
|
14
|
+
# This matches HexaPDF's external_signing convention so providers are interchangeable.
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# builder = CmsBuilder.new(certificate: cert, certificate_chain: [])
|
|
18
|
+
# cms_der = builder.build(byterange_content) { |hash| private_key.sign_raw("SHA256", hash) }
|
|
19
|
+
#
|
|
20
|
+
class CmsBuilder
|
|
21
|
+
# OIDs used in PDF CMS signatures
|
|
22
|
+
OID_DATA = "1.2.840.113549.1.7.1" # id-data
|
|
23
|
+
OID_SIGNED_DATA = "1.2.840.113549.1.7.2" # id-signedData
|
|
24
|
+
OID_CONTENT_TYPE = "1.2.840.113549.1.9.3" # id-contentType
|
|
25
|
+
OID_MESSAGE_DIGEST = "1.2.840.113549.1.9.4" # id-messageDigest
|
|
26
|
+
OID_SIGNING_TIME = "1.2.840.113549.1.9.5" # id-signingTime
|
|
27
|
+
OID_SHA256 = "2.16.840.1.101.3.4.2.1" # id-sha256
|
|
28
|
+
OID_RSA = "1.2.840.113549.1.1.1" # rsaEncryption
|
|
29
|
+
OID_SHA256_RSA = "1.2.840.113549.1.1.11" # sha256WithRSAEncryption
|
|
30
|
+
|
|
31
|
+
# @param certificate [OpenSSL::X509::Certificate] signer certificate
|
|
32
|
+
# @param certificate_chain [Array<OpenSSL::X509::Certificate>] extra certs (intermediate CA etc.)
|
|
33
|
+
# @param signing_time [Time] time to embed in signed attributes (default: now)
|
|
34
|
+
def initialize(certificate:, certificate_chain: [], signing_time: nil)
|
|
35
|
+
@certificate = certificate
|
|
36
|
+
@certificate_chain = certificate_chain
|
|
37
|
+
@signing_time = signing_time || Time.now
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Build CMS SignedData DER for the given ByteRange content.
|
|
41
|
+
#
|
|
42
|
+
# @param data [String] concatenated ByteRange bytes from the PDF
|
|
43
|
+
# @yield [hash] SHA256 hash of the signed-attributes DER (binary String)
|
|
44
|
+
# @yieldreturn [String] raw RSA signature bytes (PKCS#1 v1.5)
|
|
45
|
+
# @return [String] DER-encoded CMS ContentInfo
|
|
46
|
+
def build(data)
|
|
47
|
+
raise ArgumentError, "sign_bytes block required" unless block_given?
|
|
48
|
+
|
|
49
|
+
message_digest = OpenSSL::Digest::SHA256.digest(data)
|
|
50
|
+
signed_attrs_der = build_signed_attrs_der(message_digest)
|
|
51
|
+
|
|
52
|
+
# Hash of the DER-encoded signed attributes SET — this is what sign_raw signs
|
|
53
|
+
hash = OpenSSL::Digest::SHA256.digest(signed_attrs_der)
|
|
54
|
+
signature = yield(hash)
|
|
55
|
+
|
|
56
|
+
build_content_info(signature, signed_attrs_der)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# Construct the signed attributes as a DER-encoded SET.
|
|
62
|
+
# The result is hashed and signed; then stored as [0] IMPLICIT in SignerInfo.
|
|
63
|
+
def build_signed_attrs_der(message_digest)
|
|
64
|
+
attrs = [
|
|
65
|
+
attribute(OID_CONTENT_TYPE, OpenSSL::ASN1::ObjectId.new(OID_DATA)),
|
|
66
|
+
attribute(OID_MESSAGE_DIGEST, OpenSSL::ASN1::OctetString.new(message_digest)),
|
|
67
|
+
attribute(OID_SIGNING_TIME, OpenSSL::ASN1::UTCTime.new(@signing_time))
|
|
68
|
+
]
|
|
69
|
+
OpenSSL::ASN1::Set.new(attrs).to_der
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Build a CMS Attribute: SEQUENCE { OID, SET { value } }
|
|
73
|
+
def attribute(oid_str, value)
|
|
74
|
+
OpenSSL::ASN1::Sequence.new([
|
|
75
|
+
OpenSSL::ASN1::ObjectId.new(oid_str),
|
|
76
|
+
OpenSSL::ASN1::Set.new([value])
|
|
77
|
+
])
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Build the top-level CMS ContentInfo wrapping SignedData
|
|
81
|
+
def build_content_info(signature, signed_attrs_der)
|
|
82
|
+
signed_data = build_signed_data(signature, signed_attrs_der)
|
|
83
|
+
|
|
84
|
+
OpenSSL::ASN1::Sequence.new([
|
|
85
|
+
OpenSSL::ASN1::ObjectId.new(OID_SIGNED_DATA),
|
|
86
|
+
OpenSSL::ASN1::ASN1Data.new([signed_data], 0, :CONTEXT_SPECIFIC) # [0] EXPLICIT
|
|
87
|
+
]).to_der
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def build_signed_data(signature, signed_attrs_der)
|
|
91
|
+
sha256_alg = algorithm_identifier(OID_SHA256)
|
|
92
|
+
rsa_alg = algorithm_identifier(OID_RSA)
|
|
93
|
+
signer_info = build_signer_info(signature, signed_attrs_der, sha256_alg, rsa_alg)
|
|
94
|
+
|
|
95
|
+
# certificates [0] IMPLICIT — DER of each cert wrapped as context-specific
|
|
96
|
+
all_certs = [@certificate, *@certificate_chain]
|
|
97
|
+
cert_nodes = all_certs.map { |c| OpenSSL::ASN1.decode(c.to_der) }
|
|
98
|
+
certs_tagged = OpenSSL::ASN1::ASN1Data.new(cert_nodes, 0, :CONTEXT_SPECIFIC)
|
|
99
|
+
|
|
100
|
+
# encapContentInfo: just the eContentType, no eContent (detached)
|
|
101
|
+
encap = OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::ObjectId.new(OID_DATA)])
|
|
102
|
+
|
|
103
|
+
OpenSSL::ASN1::Sequence.new([
|
|
104
|
+
OpenSSL::ASN1::Integer.new(OpenSSL::BN.new(1)), # version 1
|
|
105
|
+
OpenSSL::ASN1::Set.new([sha256_alg]), # digestAlgorithms
|
|
106
|
+
encap, # encapContentInfo
|
|
107
|
+
certs_tagged, # certificates [0]
|
|
108
|
+
OpenSSL::ASN1::Set.new([signer_info]) # signerInfos
|
|
109
|
+
])
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def build_signer_info(signature, signed_attrs_der, sha256_alg, rsa_alg)
|
|
113
|
+
# IssuerAndSerialNumber
|
|
114
|
+
issuer_and_serial = OpenSSL::ASN1::Sequence.new([
|
|
115
|
+
OpenSSL::ASN1.decode(@certificate.issuer.to_der),
|
|
116
|
+
OpenSSL::ASN1::Integer.new(@certificate.serial)
|
|
117
|
+
])
|
|
118
|
+
|
|
119
|
+
# signedAttrs stored as [0] IMPLICIT (same bytes as SET but tag = 0xA0)
|
|
120
|
+
parsed_set = OpenSSL::ASN1.decode(signed_attrs_der)
|
|
121
|
+
signed_attrs_0 = OpenSSL::ASN1::ASN1Data.new(parsed_set.value, 0, :CONTEXT_SPECIFIC)
|
|
122
|
+
|
|
123
|
+
OpenSSL::ASN1::Sequence.new([
|
|
124
|
+
OpenSSL::ASN1::Integer.new(OpenSSL::BN.new(1)), # version 1
|
|
125
|
+
issuer_and_serial,
|
|
126
|
+
sha256_alg, # digestAlgorithm
|
|
127
|
+
signed_attrs_0, # signedAttrs [0] IMPLICIT
|
|
128
|
+
rsa_alg, # signatureAlgorithm
|
|
129
|
+
OpenSSL::ASN1::OctetString.new(signature) # signature
|
|
130
|
+
])
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# AlgorithmIdentifier SEQUENCE { OID, NULL }
|
|
134
|
+
def algorithm_identifier(oid_str)
|
|
135
|
+
OpenSSL::ASN1::Sequence.new([
|
|
136
|
+
OpenSSL::ASN1::ObjectId.new(oid_str),
|
|
137
|
+
OpenSSL::ASN1::Null.new(nil)
|
|
138
|
+
])
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "pdf-reader"
|
|
5
|
+
require_relative "cms_builder"
|
|
6
|
+
|
|
7
|
+
module EasyCodeSign
|
|
8
|
+
module Pdf
|
|
9
|
+
# Signs a PDF using a pure-OpenSSL/MIT approach (no HexaPDF).
|
|
10
|
+
#
|
|
11
|
+
# Implements ISO 32000 incremental-update signature:
|
|
12
|
+
# 1. Appends new objects (sig dict, sig field, updated catalog) to the original PDF.
|
|
13
|
+
# 2. Appends a cross-reference table and trailer (/Prev chains to original).
|
|
14
|
+
# 3. Writes a fixed-size /Contents placeholder (zeros).
|
|
15
|
+
# 4. Computes ByteRange = [0, placeholder_start, placeholder_end, rest_of_file].
|
|
16
|
+
# 5. Patches ByteRange into the file (fixed-width replacement).
|
|
17
|
+
# 6. Signs the ByteRange content via CmsBuilder + the caller's sign_bytes block.
|
|
18
|
+
# 7. Embeds the CMS DER hex into the /Contents slot.
|
|
19
|
+
#
|
|
20
|
+
class NativeSigner
|
|
21
|
+
# Reserved binary bytes for CMS DER. RSA-2048 + self-signed cert < 4 KiB.
|
|
22
|
+
# 8 KiB gives headroom for small certificate chains.
|
|
23
|
+
SIGNATURE_PLACEHOLDER_SIZE = 8192
|
|
24
|
+
|
|
25
|
+
# Fixed-width ByteRange placeholder written before the real values are known.
|
|
26
|
+
# Four 10-digit zero-padded integers + brackets = always 44 chars.
|
|
27
|
+
BR_PLACEHOLDER = "[0000000000 0000000000 0000000000 0000000000]"
|
|
28
|
+
|
|
29
|
+
def initialize(pdf_path:, output_path:, certificate:, certificate_chain: [],
|
|
30
|
+
reason: nil, location: nil, contact_info: nil, signing_time: nil)
|
|
31
|
+
@pdf_path = pdf_path
|
|
32
|
+
@output_path = output_path
|
|
33
|
+
@certificate = certificate
|
|
34
|
+
@certificate_chain = certificate_chain
|
|
35
|
+
@reason = reason
|
|
36
|
+
@location = location
|
|
37
|
+
@contact_info = contact_info
|
|
38
|
+
@signing_time = signing_time || Time.now
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Produce a signed PDF.
|
|
42
|
+
# @yield [hash] binary String — SHA256(signed_attrs_DER), per HexaPDF external_signing convention
|
|
43
|
+
# @yieldreturn [String] raw RSA-PKCS#1-v1.5 signature bytes
|
|
44
|
+
# @return [String] @output_path
|
|
45
|
+
def sign(&sign_bytes)
|
|
46
|
+
raise ArgumentError, "sign_bytes block required" unless sign_bytes
|
|
47
|
+
|
|
48
|
+
original = File.binread(@pdf_path)
|
|
49
|
+
reader = PDF::Reader.new(StringIO.new(original))
|
|
50
|
+
trailer = reader.objects.trailer
|
|
51
|
+
|
|
52
|
+
orig_startxref = find_startxref(original)
|
|
53
|
+
orig_size = trailer[:Size].to_i
|
|
54
|
+
root_ref = trailer[:Root]
|
|
55
|
+
|
|
56
|
+
# New object numbers
|
|
57
|
+
sig_obj_num = orig_size
|
|
58
|
+
field_obj_num = orig_size + 1
|
|
59
|
+
|
|
60
|
+
# The incremental update begins after a "\n" separator
|
|
61
|
+
base = original.bytesize + 1 # +1 for the separator newline
|
|
62
|
+
|
|
63
|
+
# Build object bodies and track their absolute offsets in the combined file
|
|
64
|
+
sig_body, contents_body_rel = build_sig_body(sig_obj_num)
|
|
65
|
+
sig_offset = base
|
|
66
|
+
contents_abs = sig_offset + contents_body_rel
|
|
67
|
+
|
|
68
|
+
field_body = build_field_body(field_obj_num, sig_obj_num)
|
|
69
|
+
field_offset = sig_offset + sig_body.bytesize
|
|
70
|
+
|
|
71
|
+
catalog_obj_num = root_ref.id
|
|
72
|
+
catalog_gen = root_ref.gen
|
|
73
|
+
catalog_body = build_catalog_body(catalog_obj_num, catalog_gen,
|
|
74
|
+
field_obj_num, root_ref, reader)
|
|
75
|
+
catalog_offset = field_offset + field_body.bytesize
|
|
76
|
+
|
|
77
|
+
xref_offset = catalog_offset + catalog_body.bytesize
|
|
78
|
+
|
|
79
|
+
xref_str = build_xref(
|
|
80
|
+
sig_obj_num => sig_offset,
|
|
81
|
+
field_obj_num => field_offset,
|
|
82
|
+
catalog_obj_num => catalog_offset
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
new_size = [sig_obj_num, field_obj_num, catalog_obj_num].max + 1
|
|
86
|
+
trailer_str = "trailer\n" \
|
|
87
|
+
"<</Size #{new_size}" \
|
|
88
|
+
" /Root #{ref(catalog_obj_num, catalog_gen)}" \
|
|
89
|
+
" /Prev #{orig_startxref}>>\n" \
|
|
90
|
+
"startxref\n#{xref_offset}\n%%EOF\n"
|
|
91
|
+
|
|
92
|
+
combined = original +
|
|
93
|
+
"\n" +
|
|
94
|
+
sig_body + field_body + catalog_body +
|
|
95
|
+
xref_str + trailer_str
|
|
96
|
+
|
|
97
|
+
File.binwrite(@output_path, combined)
|
|
98
|
+
|
|
99
|
+
# contents_abs is the absolute offset of '<' in /Contents <hex> in the combined file
|
|
100
|
+
contents_total = SIGNATURE_PLACEHOLDER_SIZE * 2 + 2 # '<' + hex_zeros + '>'
|
|
101
|
+
byte_range = [
|
|
102
|
+
0,
|
|
103
|
+
contents_abs,
|
|
104
|
+
contents_abs + contents_total,
|
|
105
|
+
combined.bytesize - (contents_abs + contents_total)
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
patch_byte_range(byte_range)
|
|
109
|
+
|
|
110
|
+
data = read_byte_ranges(byte_range)
|
|
111
|
+
|
|
112
|
+
builder = CmsBuilder.new(
|
|
113
|
+
certificate: @certificate,
|
|
114
|
+
certificate_chain: @certificate_chain,
|
|
115
|
+
signing_time: @signing_time
|
|
116
|
+
)
|
|
117
|
+
cms_der = builder.build(data, &sign_bytes)
|
|
118
|
+
|
|
119
|
+
embed_signature(contents_abs, cms_der)
|
|
120
|
+
|
|
121
|
+
@output_path
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
# ------------------------------------------------------------------ #
|
|
127
|
+
# Object body builders #
|
|
128
|
+
# ------------------------------------------------------------------ #
|
|
129
|
+
|
|
130
|
+
# Returns [body_string, byte_offset_of_'<'_within_body]
|
|
131
|
+
def build_sig_body(obj_num)
|
|
132
|
+
hex_zeros = "0" * (SIGNATURE_PLACEHOLDER_SIZE * 2)
|
|
133
|
+
|
|
134
|
+
header = "#{obj_num} 0 obj\n" \
|
|
135
|
+
"<</Type /Sig\n" \
|
|
136
|
+
"/Filter /Adobe.PPKLite\n" \
|
|
137
|
+
"/SubFilter /adbe.pkcs7.detached\n" \
|
|
138
|
+
"/ByteRange #{BR_PLACEHOLDER}\n" \
|
|
139
|
+
"/Contents <"
|
|
140
|
+
|
|
141
|
+
footer = ">"
|
|
142
|
+
footer += "\n/Reason #{pdf_str(@reason)}" if @reason
|
|
143
|
+
footer += "\n/Location #{pdf_str(@location)}" if @location
|
|
144
|
+
footer += "\n/ContactInfo #{pdf_str(@contact_info)}" if @contact_info
|
|
145
|
+
footer += "\n>>\nendobj\n"
|
|
146
|
+
|
|
147
|
+
body = header + hex_zeros + footer
|
|
148
|
+
[body, header.bytesize - 1] # -1 → points at '<'
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def build_field_body(obj_num, sig_obj_num)
|
|
152
|
+
"#{obj_num} 0 obj\n" \
|
|
153
|
+
"<</Type /Annot\n" \
|
|
154
|
+
"/Subtype /Widget\n" \
|
|
155
|
+
"/FT /Sig\n" \
|
|
156
|
+
"/V #{ref(sig_obj_num, 0)}\n" \
|
|
157
|
+
"/Rect [0 0 0 0]\n" \
|
|
158
|
+
"/F 4\n" \
|
|
159
|
+
"/T (Signature1)\n" \
|
|
160
|
+
">>\nendobj\n"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def build_catalog_body(obj_num, gen, field_obj_num, root_ref, reader)
|
|
164
|
+
catalog = reader.objects[root_ref] || {}
|
|
165
|
+
|
|
166
|
+
parts = ["/Type /Catalog"]
|
|
167
|
+
|
|
168
|
+
if catalog[:Pages]
|
|
169
|
+
pr = catalog[:Pages]
|
|
170
|
+
parts << "/Pages #{ref(pr.id, pr.gen)}"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Preserve common catalog entries
|
|
174
|
+
%i[ViewerPreferences PageLayout PageMode Names Outlines MarkInfo Lang].each do |key|
|
|
175
|
+
val = catalog[key]
|
|
176
|
+
next unless val
|
|
177
|
+
parts << "/#{key} #{pdf_val(val)}"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
parts << "/AcroForm <</Fields [#{ref(field_obj_num, 0)}] /SigFlags 3>>"
|
|
181
|
+
|
|
182
|
+
"#{obj_num} #{gen} obj\n<<#{parts.join("\n")}>>\nendobj\n"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# ------------------------------------------------------------------ #
|
|
186
|
+
# Cross-reference table #
|
|
187
|
+
# ------------------------------------------------------------------ #
|
|
188
|
+
|
|
189
|
+
# Writes separate 1-entry subsections — always valid PDF
|
|
190
|
+
def build_xref(entries)
|
|
191
|
+
out = +"xref\n"
|
|
192
|
+
entries.sort_by { |num, _| num }.each do |num, offset|
|
|
193
|
+
out << "#{num} 1\n"
|
|
194
|
+
out << "#{offset.to_s.rjust(10, "0")} 00000 n \n"
|
|
195
|
+
end
|
|
196
|
+
out
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# ------------------------------------------------------------------ #
|
|
200
|
+
# ByteRange + signature embedding #
|
|
201
|
+
# ------------------------------------------------------------------ #
|
|
202
|
+
|
|
203
|
+
def patch_byte_range(byte_range)
|
|
204
|
+
content = File.binread(@output_path)
|
|
205
|
+
br_str = "[#{byte_range.map { |v| v.to_s.rjust(10, "0") }.join(" ")}]"
|
|
206
|
+
raise "ByteRange value too wide" if br_str.bytesize > BR_PLACEHOLDER.bytesize
|
|
207
|
+
|
|
208
|
+
idx = content.index("/ByteRange #{BR_PLACEHOLDER}")
|
|
209
|
+
raise InvalidPdfError, "ByteRange placeholder not found" unless idx
|
|
210
|
+
|
|
211
|
+
start = idx + "/ByteRange ".bytesize
|
|
212
|
+
content[start, BR_PLACEHOLDER.bytesize] = br_str.ljust(BR_PLACEHOLDER.bytesize)
|
|
213
|
+
File.binwrite(@output_path, content)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def read_byte_ranges(byte_range)
|
|
217
|
+
File.open(@output_path, "rb") do |f|
|
|
218
|
+
f.pos = byte_range[0]
|
|
219
|
+
chunk = f.read(byte_range[1])
|
|
220
|
+
f.pos = byte_range[2]
|
|
221
|
+
chunk << f.read(byte_range[3])
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def embed_signature(contents_abs, cms_der)
|
|
226
|
+
hex = cms_der.unpack1("H*")
|
|
227
|
+
if hex.bytesize > SIGNATURE_PLACEHOLDER_SIZE * 2
|
|
228
|
+
raise PdfSignatureError, "CMS DER (#{hex.bytesize / 2} bytes) exceeds reserved " \
|
|
229
|
+
"#{SIGNATURE_PLACEHOLDER_SIZE} bytes"
|
|
230
|
+
end
|
|
231
|
+
padded = hex.ljust(SIGNATURE_PLACEHOLDER_SIZE * 2, "0")
|
|
232
|
+
File.open(@output_path, "rb+") do |f|
|
|
233
|
+
f.pos = contents_abs # '<' of /Contents <hex>
|
|
234
|
+
f.write("<#{padded}>")
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# ------------------------------------------------------------------ #
|
|
239
|
+
# Helpers #
|
|
240
|
+
# ------------------------------------------------------------------ #
|
|
241
|
+
|
|
242
|
+
def find_startxref(bytes)
|
|
243
|
+
tail = bytes.byteslice([bytes.bytesize - 1024, 0].max, 1024) || bytes
|
|
244
|
+
m = tail.match(/startxref\s+(\d+)\s*%%EOF/)
|
|
245
|
+
raise InvalidPdfError, "Cannot locate startxref in PDF" unless m
|
|
246
|
+
m[1].to_i
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def ref(id, gen)
|
|
250
|
+
"#{id} #{gen} R"
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def pdf_str(str)
|
|
254
|
+
"(#{str.gsub("\\", "\\\\\\\\").gsub("(", "\\(").gsub(")", "\\)")})"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def pdf_val(val)
|
|
258
|
+
case val
|
|
259
|
+
when PDF::Reader::Reference then ref(val.id, val.gen)
|
|
260
|
+
when Symbol then "/#{val}"
|
|
261
|
+
when String then pdf_str(val)
|
|
262
|
+
when TrueClass then "true"
|
|
263
|
+
when FalseClass then "false"
|
|
264
|
+
when NilClass then "null"
|
|
265
|
+
when Array then "[#{val.map { |v| pdf_val(v) }.join(" ")}]"
|
|
266
|
+
when Hash
|
|
267
|
+
inner = val.map { |k, v| "/#{k} #{pdf_val(v)}" }.join(" ")
|
|
268
|
+
"<<#{inner}>>"
|
|
269
|
+
else
|
|
270
|
+
val.to_s
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|