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.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/.rubocop.yml +46 -7
- data/CHANGELOG.md +183 -0
- data/Gemfile +6 -0
- data/LICENSE +21 -0
- data/README.adoc +496 -127
- data/Rakefile +8 -1
- data/bin/console +1 -1
- data/capsium.gemspec +12 -19
- data/exe/capsium +1 -1
- data/lib/capsium/cli/convert.rb +18 -0
- data/lib/capsium/cli/formatting.rb +27 -0
- data/lib/capsium/cli/package.rb +148 -0
- data/lib/capsium/cli/reactor.rb +33 -0
- data/lib/capsium/cli.rb +4 -96
- data/lib/capsium/converters/jekyll.rb +5 -7
- data/lib/capsium/converters.rb +7 -0
- data/lib/capsium/package/authentication.rb +38 -0
- data/lib/capsium/package/authentication_config.rb +78 -0
- data/lib/capsium/package/cipher.rb +197 -0
- data/lib/capsium/package/composition.rb +77 -0
- data/lib/capsium/package/dataset.rb +67 -34
- data/lib/capsium/package/dependency_resolver.rb +61 -0
- data/lib/capsium/package/encryption_config.rb +38 -0
- data/lib/capsium/package/manifest.rb +33 -22
- data/lib/capsium/package/manifest_config.rb +52 -7
- data/lib/capsium/package/merged_view.rb +168 -0
- data/lib/capsium/package/metadata.rb +4 -8
- data/lib/capsium/package/metadata_config.rb +97 -9
- data/lib/capsium/package/preparation.rb +81 -0
- data/lib/capsium/package/routes.rb +39 -41
- data/lib/capsium/package/routes_config.rb +174 -44
- data/lib/capsium/package/security.rb +140 -0
- data/lib/capsium/package/security_config.rb +60 -0
- data/lib/capsium/package/signer.rb +201 -0
- data/lib/capsium/package/storage.rb +20 -45
- data/lib/capsium/package/storage_config.rb +128 -3
- data/lib/capsium/package/store.rb +109 -0
- data/lib/capsium/package/testing/config_test.rb +70 -0
- data/lib/capsium/package/testing/context.rb +11 -0
- data/lib/capsium/package/testing/data_validation_test.rb +63 -0
- data/lib/capsium/package/testing/file_test.rb +45 -0
- data/lib/capsium/package/testing/report.rb +33 -0
- data/lib/capsium/package/testing/route_test.rb +64 -0
- data/lib/capsium/package/testing/test_case.rb +60 -0
- data/lib/capsium/package/testing/test_suite.rb +86 -0
- data/lib/capsium/package/testing.rb +20 -0
- data/lib/capsium/package/validator.rb +160 -0
- data/lib/capsium/package/verification.rb +32 -0
- data/lib/capsium/package/version.rb +204 -0
- data/lib/capsium/package.rb +92 -106
- data/lib/capsium/packager.rb +75 -6
- data/lib/capsium/reactor/authenticator.rb +180 -0
- data/lib/capsium/reactor/deploy.rb +71 -0
- data/lib/capsium/reactor/htpasswd.rb +154 -0
- data/lib/capsium/reactor/introspection.rb +91 -0
- data/lib/capsium/reactor/oauth2.rb +106 -0
- data/lib/capsium/reactor/serving.rb +75 -0
- data/lib/capsium/reactor/session.rb +82 -0
- data/lib/capsium/reactor.rb +93 -43
- data/lib/capsium/thor_ext.rb +1 -1
- data/lib/capsium/version.rb +1 -1
- data/lib/capsium.rb +7 -9
- data/sig/capsium/package/authentication.rbs +23 -0
- data/sig/capsium/package/authentication_config.rbs +71 -0
- data/sig/capsium/package/cipher.rbs +51 -0
- data/sig/capsium/package/composition.rbs +20 -0
- data/sig/capsium/package/dataset.rbs +28 -0
- data/sig/capsium/package/dependency_resolver.rbs +49 -0
- data/sig/capsium/package/encryption_config.rbs +35 -0
- data/sig/capsium/package/manifest.rbs +34 -0
- data/sig/capsium/package/manifest_config.rbs +38 -0
- data/sig/capsium/package/merged_view.rbs +46 -0
- data/sig/capsium/package/metadata.rbs +26 -0
- data/sig/capsium/package/metadata_config.rbs +49 -0
- data/sig/capsium/package/preparation.rbs +23 -0
- data/sig/capsium/package/routes.rbs +37 -0
- data/sig/capsium/package/routes_config.rbs +107 -0
- data/sig/capsium/package/security.rbs +72 -0
- data/sig/capsium/package/security_config.rbs +57 -0
- data/sig/capsium/package/signer.rbs +59 -0
- data/sig/capsium/package/storage.rbs +28 -0
- data/sig/capsium/package/storage_config.rbs +85 -0
- data/sig/capsium/package/store.rbs +37 -0
- data/sig/capsium/package/testing/config_test.rbs +21 -0
- data/sig/capsium/package/testing/context.rbs +14 -0
- data/sig/capsium/package/testing/data_validation_test.rbs +22 -0
- data/sig/capsium/package/testing/file_test.rbs +17 -0
- data/sig/capsium/package/testing/report.rbs +20 -0
- data/sig/capsium/package/testing/route_test.rbs +22 -0
- data/sig/capsium/package/testing/test_case.rbs +41 -0
- data/sig/capsium/package/testing/test_suite.rbs +20 -0
- data/sig/capsium/package/testing.rbs +7 -0
- data/sig/capsium/package/validator.rbs +29 -0
- data/sig/capsium/package/verification.rbs +24 -0
- data/sig/capsium/package/version.rbs +39 -0
- data/sig/capsium/package.rbs +61 -0
- data/sig/capsium/packager.rbs +32 -0
- data/sig/capsium/reactor/authenticator.rbs +40 -0
- data/sig/capsium/reactor/deploy.rbs +34 -0
- data/sig/capsium/reactor/htpasswd.rbs +36 -0
- data/sig/capsium/reactor/introspection.rbs +31 -0
- data/sig/capsium/reactor/oauth2.rbs +31 -0
- data/sig/capsium/reactor/serving.rbs +22 -0
- data/sig/capsium/reactor/session.rbs +36 -0
- data/sig/capsium/reactor.rbs +35 -0
- data/sig/capsium.rbs +3 -1
- metadata +108 -170
- data/lib/capsium/package/dataset_config.rb +0 -28
- data/lib/capsium/protector.rb +0 -103
|
@@ -1,72 +1,202 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "json"
|
|
4
|
+
require "lutaml/model"
|
|
4
5
|
|
|
5
6
|
module Capsium
|
|
6
7
|
class Package
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
105
|
+
File.join(package_path, resource)
|
|
15
106
|
end
|
|
16
107
|
|
|
17
108
|
def mime(manifest)
|
|
18
|
-
manifest.
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def
|
|
22
|
-
if
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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(
|
|
46
|
-
routes.detect { |
|
|
177
|
+
def resolve(path)
|
|
178
|
+
routes.detect { |route| route.serving_path == path }
|
|
47
179
|
end
|
|
48
180
|
|
|
49
|
-
def add(
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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(
|
|
64
|
-
|
|
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
|
-
|
|
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
|