capsium 0.1.2 → 0.3.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.
Files changed (111) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/.rubocop.yml +46 -7
  4. data/CHANGELOG.md +183 -0
  5. data/Gemfile +6 -0
  6. data/LICENSE +21 -0
  7. data/README.adoc +496 -127
  8. data/Rakefile +8 -1
  9. data/bin/console +1 -1
  10. data/capsium.gemspec +12 -19
  11. data/exe/capsium +1 -1
  12. data/lib/capsium/cli/convert.rb +18 -0
  13. data/lib/capsium/cli/formatting.rb +27 -0
  14. data/lib/capsium/cli/package.rb +148 -0
  15. data/lib/capsium/cli/reactor.rb +33 -0
  16. data/lib/capsium/cli.rb +4 -96
  17. data/lib/capsium/converters/jekyll.rb +5 -7
  18. data/lib/capsium/converters.rb +7 -0
  19. data/lib/capsium/package/authentication.rb +38 -0
  20. data/lib/capsium/package/authentication_config.rb +78 -0
  21. data/lib/capsium/package/cipher.rb +197 -0
  22. data/lib/capsium/package/composition.rb +77 -0
  23. data/lib/capsium/package/dataset.rb +67 -34
  24. data/lib/capsium/package/dependency_resolver.rb +61 -0
  25. data/lib/capsium/package/encryption_config.rb +38 -0
  26. data/lib/capsium/package/manifest.rb +33 -22
  27. data/lib/capsium/package/manifest_config.rb +52 -7
  28. data/lib/capsium/package/merged_view.rb +168 -0
  29. data/lib/capsium/package/metadata.rb +4 -8
  30. data/lib/capsium/package/metadata_config.rb +97 -9
  31. data/lib/capsium/package/preparation.rb +81 -0
  32. data/lib/capsium/package/routes.rb +39 -41
  33. data/lib/capsium/package/routes_config.rb +174 -44
  34. data/lib/capsium/package/security.rb +140 -0
  35. data/lib/capsium/package/security_config.rb +60 -0
  36. data/lib/capsium/package/signer.rb +201 -0
  37. data/lib/capsium/package/storage.rb +20 -45
  38. data/lib/capsium/package/storage_config.rb +128 -3
  39. data/lib/capsium/package/store.rb +109 -0
  40. data/lib/capsium/package/testing/config_test.rb +70 -0
  41. data/lib/capsium/package/testing/context.rb +11 -0
  42. data/lib/capsium/package/testing/data_validation_test.rb +63 -0
  43. data/lib/capsium/package/testing/file_test.rb +45 -0
  44. data/lib/capsium/package/testing/report.rb +33 -0
  45. data/lib/capsium/package/testing/route_test.rb +64 -0
  46. data/lib/capsium/package/testing/test_case.rb +60 -0
  47. data/lib/capsium/package/testing/test_suite.rb +86 -0
  48. data/lib/capsium/package/testing.rb +20 -0
  49. data/lib/capsium/package/validator.rb +160 -0
  50. data/lib/capsium/package/verification.rb +32 -0
  51. data/lib/capsium/package/version.rb +204 -0
  52. data/lib/capsium/package.rb +92 -106
  53. data/lib/capsium/packager.rb +75 -6
  54. data/lib/capsium/reactor/authenticator.rb +180 -0
  55. data/lib/capsium/reactor/deploy.rb +71 -0
  56. data/lib/capsium/reactor/htpasswd.rb +154 -0
  57. data/lib/capsium/reactor/introspection.rb +91 -0
  58. data/lib/capsium/reactor/oauth2.rb +106 -0
  59. data/lib/capsium/reactor/serving.rb +75 -0
  60. data/lib/capsium/reactor/session.rb +82 -0
  61. data/lib/capsium/reactor.rb +93 -43
  62. data/lib/capsium/thor_ext.rb +1 -1
  63. data/lib/capsium/version.rb +1 -1
  64. data/lib/capsium.rb +7 -9
  65. data/sig/capsium/package/authentication.rbs +23 -0
  66. data/sig/capsium/package/authentication_config.rbs +71 -0
  67. data/sig/capsium/package/cipher.rbs +51 -0
  68. data/sig/capsium/package/composition.rbs +20 -0
  69. data/sig/capsium/package/dataset.rbs +28 -0
  70. data/sig/capsium/package/dependency_resolver.rbs +49 -0
  71. data/sig/capsium/package/encryption_config.rbs +35 -0
  72. data/sig/capsium/package/manifest.rbs +34 -0
  73. data/sig/capsium/package/manifest_config.rbs +38 -0
  74. data/sig/capsium/package/merged_view.rbs +46 -0
  75. data/sig/capsium/package/metadata.rbs +26 -0
  76. data/sig/capsium/package/metadata_config.rbs +49 -0
  77. data/sig/capsium/package/preparation.rbs +23 -0
  78. data/sig/capsium/package/routes.rbs +37 -0
  79. data/sig/capsium/package/routes_config.rbs +107 -0
  80. data/sig/capsium/package/security.rbs +72 -0
  81. data/sig/capsium/package/security_config.rbs +57 -0
  82. data/sig/capsium/package/signer.rbs +59 -0
  83. data/sig/capsium/package/storage.rbs +28 -0
  84. data/sig/capsium/package/storage_config.rbs +85 -0
  85. data/sig/capsium/package/store.rbs +37 -0
  86. data/sig/capsium/package/testing/config_test.rbs +21 -0
  87. data/sig/capsium/package/testing/context.rbs +14 -0
  88. data/sig/capsium/package/testing/data_validation_test.rbs +22 -0
  89. data/sig/capsium/package/testing/file_test.rbs +17 -0
  90. data/sig/capsium/package/testing/report.rbs +20 -0
  91. data/sig/capsium/package/testing/route_test.rbs +22 -0
  92. data/sig/capsium/package/testing/test_case.rbs +41 -0
  93. data/sig/capsium/package/testing/test_suite.rbs +20 -0
  94. data/sig/capsium/package/testing.rbs +7 -0
  95. data/sig/capsium/package/validator.rbs +29 -0
  96. data/sig/capsium/package/verification.rbs +24 -0
  97. data/sig/capsium/package/version.rbs +39 -0
  98. data/sig/capsium/package.rbs +61 -0
  99. data/sig/capsium/packager.rbs +32 -0
  100. data/sig/capsium/reactor/authenticator.rbs +40 -0
  101. data/sig/capsium/reactor/deploy.rbs +34 -0
  102. data/sig/capsium/reactor/htpasswd.rbs +36 -0
  103. data/sig/capsium/reactor/introspection.rbs +31 -0
  104. data/sig/capsium/reactor/oauth2.rbs +31 -0
  105. data/sig/capsium/reactor/serving.rbs +22 -0
  106. data/sig/capsium/reactor/session.rbs +36 -0
  107. data/sig/capsium/reactor.rbs +35 -0
  108. data/sig/capsium.rbs +3 -1
  109. metadata +108 -170
  110. data/lib/capsium/package/dataset_config.rb +0 -28
  111. data/lib/capsium/protector.rb +0 -103
@@ -1,72 +1,202 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "shale"
3
+ require "json"
4
+ require "lutaml/model"
4
5
 
5
6
  module Capsium
6
7
  class Package
7
- class RouteTarget < Shale::Mapper
8
- attribute :file, Shale::Type::String
9
- attribute :dataset, Shale::Type::String
8
+ # The "responseRewrite" object of an inherited route (05x-routing
9
+ # section "Route Inheritance"): replaces the served body and/or
10
+ # overrides response headers.
11
+ class ResponseRewrite < Lutaml::Model::Serializable
12
+ attribute :body, :string
13
+ attribute :headers, :hash
10
14
 
11
- def fs_path(manifest)
12
- return unless file
15
+ json do
16
+ map :body, to: :body
17
+ map :headers, to: :headers
18
+ end
19
+ end
20
+
21
+ # A single route entry (ARCHITECTURE.md section 4). Kinds are
22
+ # discriminated by key, MECE:
23
+ # - {path, resource, headers?, visibility?} -- static file
24
+ # - {path, dataset, accessControl?} -- dataset route
25
+ # - {path, method, handler, ...} -- dynamic handler (parsed,
26
+ # accepted-and-ignored; reactors respond 501)
27
+ #
28
+ # Route inheritance (05x-routing): a resource of the form
29
+ # "<dependency-guid>/<path>" (a URI, i.e. containing "://") pulls
30
+ # content from a dependency package; "remap" replaces the serving
31
+ # path; "responseRewrite"/"responseHeaders" post-process the
32
+ # response; "requestHeaders" are recorded for forwarding reactors
33
+ # (the Ruby reactor serves statically and does not forward, so they
34
+ # do not alter its responses).
35
+ class Route < Lutaml::Model::Serializable
36
+ DATASET_PATH_PREFIX = "/api/v1/data/"
37
+
38
+ attribute :path, :string
39
+ attribute :resource, :string
40
+ attribute :headers, :hash
41
+ attribute :headers_file, :string
42
+ attribute :visibility, :string, values: Resource::VISIBILITIES
43
+ attribute :dataset, :string
44
+ attribute :access_control, :hash
45
+ attribute :http_method, :string
46
+ attribute :handler, :string
47
+ attribute :remap, :string
48
+ attribute :response_rewrite, ResponseRewrite
49
+ attribute :response_headers, :hash
50
+ attribute :request_headers, :hash
51
+
52
+ json do
53
+ map :path, to: :path
54
+ map :resource, to: :resource
55
+ map :headers, to: :headers
56
+ map "headersFile", to: :headers_file
57
+ map :visibility, to: :visibility
58
+ map :dataset, to: :dataset
59
+ map "accessControl", to: :access_control
60
+ map "method", to: :http_method
61
+ map :handler, to: :handler
62
+ map :remap, to: :remap
63
+ map "responseRewrite", to: :response_rewrite
64
+ map "responseHeaders", to: :response_headers
65
+ map "requestHeaders", to: :request_headers
66
+ end
67
+
68
+ def kind
69
+ return :resource if resource
70
+ return :dataset if dataset
71
+
72
+ :handler
73
+ end
74
+
75
+ def dataset_route?
76
+ kind == :dataset
77
+ end
78
+
79
+ def handler_route?
80
+ kind == :handler
81
+ end
82
+
83
+ # The URL path this route answers at: the remapped path when the
84
+ # route remaps an inherited route, its own path otherwise.
85
+ def serving_path
86
+ remap || path
87
+ end
88
+
89
+ # Whether the resource addresses content of a dependency package
90
+ # ("<dependency-guid>/<path>" — a URI rather than a
91
+ # package-relative path).
92
+ def dependency_reference?
93
+ resource.is_a?(String) && resource.include?("://")
94
+ end
95
+
96
+ # Whether the route carries route-inheritance attributes.
97
+ def inherited?
98
+ dependency_reference? || !remap.nil? || !response_rewrite.nil? ||
99
+ !response_headers.nil? || !request_headers.nil?
100
+ end
101
+
102
+ def fs_path(package_path)
103
+ return unless resource
13
104
 
14
- manifest.path_to_content_file(manifest.lookup(file)&.file)
105
+ File.join(package_path, resource)
15
106
  end
16
107
 
17
108
  def mime(manifest)
18
- manifest.lookup(file)&.mime
19
- end
20
-
21
- def validate(manifest, storage)
22
- if file
23
- target_path = fs_path(manifest)
24
- unless target_path && File.exist?(target_path) && target_path.to_s.start_with?(manifest.content_path.to_s)
25
- raise "Route target does not exist or is outside of the content directory: #{target_path}"
26
- end
27
- elsif dataset
28
- unless storage.datasets.any? { |ds| ds.config.name == dataset }
29
- raise "Dataset target does not exist: #{dataset}"
30
- end
31
- else
32
- raise "Route target must have either a file or a dataset"
109
+ manifest.type_for(resource)
110
+ end
111
+
112
+ def validate_target(package_path, storage, merged_view: nil)
113
+ return if handler_route?
114
+ return if dependency_reference?
115
+
116
+ if dataset_route?
117
+ return if storage.dataset(dataset)
118
+
119
+ raise Error, "Route dataset does not exist: #{dataset}"
33
120
  end
121
+ validate_resource_target(package_path, merged_view)
34
122
  end
35
- end
36
123
 
37
- class Route < Shale::Mapper
38
- attribute :path, Shale::Type::String
39
- attribute :target, RouteTarget
124
+ private
125
+
126
+ def validate_resource_target(package_path, merged_view)
127
+ found = if merged_view
128
+ merged_view.resolve(resource)
129
+ else
130
+ target_path = fs_path(package_path)
131
+ target_path && File.exist?(target_path)
132
+ end
133
+ return if found
134
+
135
+ raise Error, "Route resource does not exist: #{resource}"
136
+ end
40
137
  end
41
138
 
42
- class RoutesConfig < Shale::Mapper
43
- attribute :routes, Route, collection: true
139
+ # Canonical routes.json model: optional top-level "index" plus a
140
+ # "routes" array. The legacy gem form ({path, target: {file|dataset}})
141
+ # is accepted on read and normalized; writers emit only the canonical
142
+ # form.
143
+ class RoutesConfig < Lutaml::Model::Serializable
144
+ attribute :index, :string
145
+ attribute :routes, Route, collection: true, default: []
146
+
147
+ json do
148
+ map :index, to: :index
149
+ map :routes, with: { from: :routes_from_json, to: :routes_to_json }
150
+ end
151
+
152
+ def self.from_json(json)
153
+ doc = JSON.parse(json)
154
+ doc["routes"] = (doc["routes"] || []).map { |route| normalize_route(route) }
155
+ super(JSON.generate(doc))
156
+ end
157
+
158
+ def self.normalize_route(route)
159
+ target = route.delete("target")
160
+ return route unless target.is_a?(Hash)
161
+
162
+ route.merge("resource" => target["file"], "dataset" => target["dataset"]).compact
163
+ end
164
+ private_class_method :normalize_route
165
+
166
+ def routes_from_json(model, value)
167
+ model.routes = (value || []).map { |route| Route.from_json(JSON.generate(route)) }
168
+ end
169
+
170
+ # Writers emit the canonical form only: deterministic, sorted by path.
171
+ def routes_to_json(model, doc)
172
+ doc["routes"] = model.routes.sort_by(&:path).map do |route|
173
+ JSON.parse(route.to_json)
174
+ end
175
+ end
44
176
 
45
- def resolve(route)
46
- routes.detect { |r| r.path == route }
177
+ def resolve(path)
178
+ routes.detect { |route| route.serving_path == path }
47
179
  end
48
180
 
49
- def add(route, target)
50
- target = RouteTarget.new(file: target) if target.is_a?(String)
51
- r = Route.new(path: route, target: target)
52
- @routes << r
53
- r
181
+ def add(path, target)
182
+ route = Route.new(path: path, resource: target)
183
+ self.routes = routes + [route]
184
+ route
54
185
  end
55
186
 
56
- def update(route, updated_route, updated_target)
57
- r = resolve(route)
58
- r.path = updated_route
59
- r.target = updated_target
60
- r
187
+ def update(path, updated_path, updated_target)
188
+ route = resolve(path)
189
+ route.path = updated_path
190
+ route.resource = updated_target
191
+ route
61
192
  end
62
193
 
63
- def remove(route)
64
- r = resolve(route)
65
- @routes.delete(r)
194
+ def remove(path)
195
+ self.routes = routes.reject { |route| route.path == path }
66
196
  end
67
197
 
68
198
  def sort!
69
- @routes.sort_by!(&:path)
199
+ self.routes = routes.sort_by(&:path)
70
200
  self
71
201
  end
72
202
  end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "forwardable"
5
+ require "json"
6
+
7
+ module Capsium
8
+ class Package
9
+ # Loads, generates and verifies security.json (ARCHITECTURE.md
10
+ # section 6). Checksums cover every file in the package except
11
+ # security.json itself and signature.sig (the signature signs the
12
+ # checksum-covered payload, so it cannot be part of it).
13
+ class Security
14
+ extend Forwardable
15
+
16
+ class IntegrityError < Capsium::Error; end
17
+
18
+ ChecksumMismatch = Data.define(:path, :expected, :actual) do
19
+ def message
20
+ "checksum mismatch: #{path}"
21
+ end
22
+ end
23
+ MissingFile = Data.define(:path, :expected) do
24
+ def message
25
+ "checksum listed but file missing: #{path}"
26
+ end
27
+ end
28
+ UncheckedFile = Data.define(:path) do
29
+ def message
30
+ "file not covered by checksums: #{path}"
31
+ end
32
+ end
33
+
34
+ attr_reader :path, :config
35
+
36
+ def_delegators :@config, :to_json, :to_hash
37
+
38
+ def initialize(path, config = nil)
39
+ @path = path
40
+ @config = config || load_config
41
+ end
42
+
43
+ def self.generate(package_path, digital_signatures: nil)
44
+ config = SecurityConfig.new(
45
+ security: SecurityData.new(
46
+ integrity_checks: IntegrityChecks.new(
47
+ checksum_algorithm: "SHA-256",
48
+ checksums: checksums_for(package_path)
49
+ ),
50
+ digital_signatures: digital_signatures
51
+ )
52
+ )
53
+ new(File.join(package_path, SECURITY_FILE), config)
54
+ end
55
+
56
+ def self.checksums_for(package_path)
57
+ package_files(package_path).sort.to_h do |relative_path|
58
+ [relative_path, Digest::SHA256.file(File.join(package_path, relative_path)).hexdigest]
59
+ end
60
+ end
61
+
62
+ def self.package_files(package_path)
63
+ files = Dir.glob(File.join(package_path, "**", "*")).select do |file|
64
+ File.file?(file)
65
+ end
66
+ files.map { |file| file.delete_prefix("#{package_path}/") }
67
+ .reject { |relative_path| excluded?(relative_path) }
68
+ end
69
+
70
+ def self.excluded?(relative_path)
71
+ [SECURITY_FILE, SIGNATURE_FILE].include?(relative_path)
72
+ end
73
+
74
+ def present?
75
+ !@config.nil?
76
+ end
77
+
78
+ # The declared digitalSignatures block, or nil when absent.
79
+ def digital_signatures
80
+ return unless present? && config.security
81
+
82
+ config.security.digital_signatures
83
+ end
84
+
85
+ def signed?
86
+ !digital_signatures&.signature_file.nil?
87
+ end
88
+
89
+ def checksums
90
+ present? ? config.checksums : {}
91
+ end
92
+
93
+ def verify(package_path)
94
+ expected = checksums
95
+ errors = verify_present_files(package_path, expected)
96
+ expected.each do |relative_path, checksum|
97
+ next if File.file?(File.join(package_path, relative_path))
98
+
99
+ errors << MissingFile.new(path: relative_path, expected: checksum)
100
+ end
101
+ errors
102
+ end
103
+
104
+ def verify!(package_path)
105
+ errors = verify(package_path)
106
+ return if errors.empty?
107
+
108
+ raise IntegrityError,
109
+ "Package integrity check failed: #{errors.map(&:path).join(', ')}"
110
+ end
111
+
112
+ def save_to_file(output_path = @path)
113
+ File.write(output_path, to_json)
114
+ end
115
+
116
+ private
117
+
118
+ def load_config
119
+ return unless File.exist?(@path)
120
+
121
+ SecurityConfig.from_json(File.read(@path))
122
+ end
123
+
124
+ def verify_present_files(package_path, expected)
125
+ self.class.package_files(package_path).filter_map do |relative_path|
126
+ file_path = File.join(package_path, relative_path)
127
+ if expected.key?(relative_path)
128
+ actual = Digest::SHA256.file(file_path).hexdigest
129
+ unless actual == expected[relative_path]
130
+ next ChecksumMismatch.new(path: relative_path, expected: expected[relative_path],
131
+ actual: actual)
132
+ end
133
+ else
134
+ UncheckedFile.new(path: relative_path)
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Capsium
6
+ class Package
7
+ # The "integrityChecks" object of security.json (ARCHITECTURE.md
8
+ # section 6): SHA-256 checksums over every package file except
9
+ # security.json itself, keyed by package-relative path.
10
+ class IntegrityChecks < Lutaml::Model::Serializable
11
+ ALGORITHMS = %w[SHA-256].freeze
12
+
13
+ attribute :checksum_algorithm, :string, values: ALGORITHMS, default: "SHA-256"
14
+ attribute :checksums, :hash, default: {}
15
+
16
+ json do
17
+ map "checksumAlgorithm", to: :checksum_algorithm
18
+ map :checksums, to: :checksums
19
+ end
20
+ end
21
+
22
+ # The "digitalSignatures" object of security.json. Signing is a later
23
+ # phase; the model parses the canonical fields only.
24
+ class DigitalSignatures < Lutaml::Model::Serializable
25
+ attribute :public_key, :string
26
+ attribute :signature_file, :string
27
+
28
+ json do
29
+ map "publicKey", to: :public_key
30
+ map "signatureFile", to: :signature_file
31
+ end
32
+ end
33
+
34
+ # The "security" object of security.json.
35
+ class SecurityData < Lutaml::Model::Serializable
36
+ attribute :integrity_checks, IntegrityChecks
37
+ attribute :digital_signatures, DigitalSignatures
38
+
39
+ json do
40
+ map "integrityChecks", to: :integrity_checks
41
+ map "digitalSignatures", to: :digital_signatures
42
+ end
43
+ end
44
+
45
+ # Canonical security.json model (generated at pack time).
46
+ class SecurityConfig < Lutaml::Model::Serializable
47
+ attribute :security, SecurityData
48
+
49
+ json do
50
+ map :security, to: :security
51
+ end
52
+
53
+ def checksums
54
+ return {} unless security&.integrity_checks
55
+
56
+ security.integrity_checks.checksums
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Capsium
6
+ class Package
7
+ # Signs and verifies Capsium packages with RSA-SHA256 digital
8
+ # signatures (05x-packaging "Digital Signature Using X.509",
9
+ # 05x-security "Digital Signatures").
10
+ #
11
+ # Signed payload construction (deterministic, openssl-verifiable): the
12
+ # concatenation, in sorted package-relative path order, of the raw
13
+ # bytes of every file covered by the security.json integrity checksums
14
+ # — i.e. every package file except security.json and signature.sig.
15
+ # The signature itself is the raw RSA-SHA256 signature over that
16
+ # payload, stored in signature.sig. Equivalent openssl verification:
17
+ #
18
+ # openssl dgst -sha256 -verify pubkey.pem -signature signature.sig payload.bin
19
+ #
20
+ # where payload.bin is the concatenation described above.
21
+ class Signer
22
+ ALGORITHM = "RSA-SHA256"
23
+ MIN_KEY_BITS = 2048
24
+
25
+ # Package-relative name of the embedded public key PEM recorded in
26
+ # security.json digitalSignatures.publicKey.
27
+ PUBLIC_KEY_FILE = "signature.pub.pem"
28
+
29
+ # Structural problems: no signature declared, missing signature or
30
+ # key files, unloadable keys, checksum-covered files missing.
31
+ class SignatureError < Capsium::Error; end
32
+
33
+ # A signature is declared but does not match the package contents.
34
+ class SignatureMismatchError < SignatureError; end
35
+
36
+ # Signature operations requested on a package whose security.json
37
+ # declares no digitalSignatures.
38
+ class UnsignedPackageError < SignatureError; end
39
+
40
+ attr_reader :package_path
41
+
42
+ def initialize(package_path)
43
+ @package_path = package_path
44
+ end
45
+
46
+ # Signs a package directory in place, or a .cap file (unpacked,
47
+ # signed, recompressed). Returns the signed path.
48
+ def self.sign_package(path, private_key_path, certificate_path = nil)
49
+ return new(path).sign(private_key_path, certificate_path) unless cap?(path)
50
+
51
+ Packager.new.transform_cap(path) { |dir| new(dir).sign(private_key_path, certificate_path) }
52
+ path
53
+ end
54
+
55
+ # Verifies the declared signature of a package directory or .cap
56
+ # file (false on mismatch; raises typed SignatureError subclasses
57
+ # on structural problems, e.g. an unsigned package).
58
+ def self.verify_package(path, public_key_path = nil)
59
+ return new(path).verify(public_key_path) unless cap?(path)
60
+
61
+ Packager.new.with_unpacked_cap(path) { |dir| new(dir).verify(public_key_path) }
62
+ end
63
+
64
+ def self.cap?(path)
65
+ File.extname(path) == ".cap"
66
+ end
67
+ private_class_method :cap?
68
+
69
+ # Signs the package directory in place: embeds the public key PEM,
70
+ # regenerates security.json (checksums plus digitalSignatures) and
71
+ # writes the raw RSA-SHA256 signature to signature.sig. When a
72
+ # certificate is given it must match the private key, and the
73
+ # certificate's public key is embedded instead.
74
+ def sign(private_key_path, certificate_path = nil)
75
+ private_key = load_private_key(private_key_path)
76
+ File.write(public_key_path, public_pem_for(private_key, certificate_path))
77
+ security = Security.generate(@package_path, digital_signatures: digital_signatures)
78
+ security.save_to_file
79
+ File.binwrite(signature_path, private_key.sign("SHA256", payload(security)))
80
+ signature_path
81
+ end
82
+
83
+ # True when the declared signature verifies against the payload.
84
+ # Without an explicit key path, the embedded public key is used.
85
+ # Raises typed SignatureError subclasses on structural problems.
86
+ def verify(public_key_path = nil)
87
+ signature = read_signature
88
+ data = payload(declared_security)
89
+ public_key(public_key_path).verify("SHA256", signature, data)
90
+ rescue OpenSSL::PKey::PKeyError
91
+ false
92
+ end
93
+
94
+ def verify!(public_key_path = nil)
95
+ return true if verify(public_key_path)
96
+
97
+ raise SignatureMismatchError,
98
+ "digital signature does not match the package contents"
99
+ end
100
+
101
+ def signed?
102
+ declared_security.signed?
103
+ end
104
+
105
+ private
106
+
107
+ def declared_security
108
+ Security.new(File.join(@package_path, Package::SECURITY_FILE))
109
+ end
110
+
111
+ def digital_signatures
112
+ DigitalSignatures.new(public_key: PUBLIC_KEY_FILE, signature_file: Package::SIGNATURE_FILE)
113
+ end
114
+
115
+ # The canonical signed payload: the concatenation, in sorted
116
+ # package-relative path order, of the bytes of every file covered by
117
+ # the integrity checksums.
118
+ def payload(security)
119
+ security.checksums.keys.sort.map do |relative_path|
120
+ file_path = File.join(@package_path, relative_path)
121
+ unless File.file?(file_path)
122
+ raise SignatureError, "file covered by checksums is missing: #{relative_path}"
123
+ end
124
+
125
+ File.binread(file_path)
126
+ end.join
127
+ end
128
+
129
+ def read_signature
130
+ unless signed?
131
+ raise UnsignedPackageError,
132
+ "package is not signed (security.json declares no digitalSignatures)"
133
+ end
134
+ return File.binread(signature_path) if File.file?(signature_path)
135
+
136
+ raise SignatureError, "signature file missing: #{signature_file_name}"
137
+ end
138
+
139
+ def signature_file_name
140
+ declared_security.digital_signatures.signature_file || Package::SIGNATURE_FILE
141
+ end
142
+
143
+ def signature_path
144
+ File.join(@package_path, signature_file_name)
145
+ end
146
+
147
+ def public_key_path
148
+ File.join(@package_path, PUBLIC_KEY_FILE)
149
+ end
150
+
151
+ def public_key(public_key_path)
152
+ pem, source = public_key_pem(public_key_path)
153
+ OpenSSL::PKey::RSA.new(pem)
154
+ rescue OpenSSL::PKey::PKeyError
155
+ certificate_public_key(pem, source)
156
+ end
157
+
158
+ def public_key_pem(public_key_path)
159
+ return [File.read(public_key_path), public_key_path] if public_key_path
160
+
161
+ embedded = declared_security.digital_signatures&.public_key || PUBLIC_KEY_FILE
162
+ embedded_path = File.join(@package_path, embedded)
163
+ return [File.read(embedded_path), embedded] if File.file?(embedded_path)
164
+
165
+ raise SignatureError, "public key file missing: #{embedded}"
166
+ end
167
+
168
+ def certificate_public_key(pem, source)
169
+ OpenSSL::X509::Certificate.new(pem).public_key
170
+ rescue OpenSSL::X509::CertificateError
171
+ raise SignatureError, "cannot load public key or certificate: #{source}"
172
+ end
173
+
174
+ def load_private_key(private_key_path)
175
+ key = OpenSSL::PKey::RSA.new(File.read(private_key_path))
176
+ return key if key.n.num_bits >= MIN_KEY_BITS
177
+
178
+ raise SignatureError, "RSA key too short: minimum #{MIN_KEY_BITS} bits required"
179
+ rescue OpenSSL::PKey::PKeyError, Errno::ENOENT
180
+ raise SignatureError, "cannot load private key: #{private_key_path}"
181
+ end
182
+
183
+ def public_pem_for(private_key, certificate_path)
184
+ return private_key.public_key.to_pem unless certificate_path
185
+
186
+ certificate = load_certificate(certificate_path)
187
+ unless certificate.check_private_key(private_key)
188
+ raise SignatureError, "certificate does not match the private key: #{certificate_path}"
189
+ end
190
+
191
+ certificate.public_key.to_pem
192
+ end
193
+
194
+ def load_certificate(certificate_path)
195
+ OpenSSL::X509::Certificate.new(File.read(certificate_path))
196
+ rescue OpenSSL::X509::CertificateError, Errno::ENOENT
197
+ raise SignatureError, "cannot load certificate: #{certificate_path}"
198
+ end
199
+ end
200
+ end
201
+ end