capsium 0.2.0 → 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/.rubocop.yml +12 -0
- data/CHANGELOG.md +125 -0
- data/README.adoc +332 -5
- data/capsium.gemspec +1 -0
- data/lib/capsium/cli/formatting.rb +27 -0
- data/lib/capsium/cli/package.rb +68 -6
- data/lib/capsium/cli/reactor.rb +9 -1
- data/lib/capsium/cli.rb +1 -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/dependency_resolver.rb +61 -0
- data/lib/capsium/package/encryption_config.rb +38 -0
- data/lib/capsium/package/merged_view.rb +168 -0
- data/lib/capsium/package/preparation.rb +81 -0
- data/lib/capsium/package/routes_config.rb +60 -6
- data/lib/capsium/package/security.rb +21 -4
- data/lib/capsium/package/signer.rb +201 -0
- data/lib/capsium/package/storage_config.rb +29 -1
- 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 +14 -3
- data/lib/capsium/package/verification.rb +32 -0
- data/lib/capsium/package/version.rb +204 -0
- data/lib/capsium/package.rb +59 -75
- data/lib/capsium/packager.rb +34 -1
- 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 +4 -1
- 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 +43 -41
- data/lib/capsium/thor_ext.rb +1 -1
- data/lib/capsium/version.rb +1 -1
- 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/dependency_resolver.rbs +49 -0
- data/sig/capsium/package/encryption_config.rbs +35 -0
- data/sig/capsium/package/merged_view.rbs +46 -0
- data/sig/capsium/package/preparation.rbs +23 -0
- data/sig/capsium/package/routes_config.rbs +36 -3
- data/sig/capsium/package/security.rbs +9 -2
- data/sig/capsium/package/signer.rbs +59 -0
- data/sig/capsium/package/storage_config.rbs +26 -2
- 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/verification.rbs +24 -0
- data/sig/capsium/package/version.rbs +39 -0
- data/sig/capsium/package.rbs +25 -17
- data/sig/capsium/packager.rbs +9 -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/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 +6 -1
- metadata +70 -1
data/lib/capsium/package.rb
CHANGED
|
@@ -6,58 +6,88 @@ require "tmpdir"
|
|
|
6
6
|
|
|
7
7
|
module Capsium
|
|
8
8
|
class Package
|
|
9
|
+
autoload :Cipher, "capsium/package/cipher"
|
|
10
|
+
autoload :CircularDependencyError, "capsium/package/dependency_resolver"
|
|
11
|
+
autoload :Composition, "capsium/package/composition"
|
|
12
|
+
autoload :Authentication, "capsium/package/authentication"
|
|
13
|
+
autoload :AuthenticationConfig, "capsium/package/authentication_config"
|
|
14
|
+
autoload :AuthenticationData, "capsium/package/authentication_config"
|
|
15
|
+
autoload :BasicAuthConfig, "capsium/package/authentication_config"
|
|
9
16
|
autoload :Dataset, "capsium/package/dataset"
|
|
10
17
|
autoload :DatasetConfig, "capsium/package/storage_config"
|
|
18
|
+
autoload :DependencyError, "capsium/package/dependency_resolver"
|
|
19
|
+
autoload :DependencyNotFoundError, "capsium/package/dependency_resolver"
|
|
20
|
+
autoload :DependencyResolver, "capsium/package/dependency_resolver"
|
|
21
|
+
autoload :DependencyVisibilityError, "capsium/package/dependency_resolver"
|
|
11
22
|
autoload :DigitalSignatures, "capsium/package/security_config"
|
|
23
|
+
autoload :EncryptionConfig, "capsium/package/encryption_config"
|
|
24
|
+
autoload :EncryptionEnvelope, "capsium/package/encryption_config"
|
|
12
25
|
autoload :IntegrityChecks, "capsium/package/security_config"
|
|
26
|
+
autoload :LayerConfig, "capsium/package/storage_config"
|
|
13
27
|
autoload :Manifest, "capsium/package/manifest"
|
|
14
28
|
autoload :ManifestConfig, "capsium/package/manifest_config"
|
|
29
|
+
autoload :MergedView, "capsium/package/merged_view"
|
|
15
30
|
autoload :Metadata, "capsium/package/metadata"
|
|
16
31
|
autoload :MetadataData, "capsium/package/metadata_config"
|
|
32
|
+
autoload :OAuth2Config, "capsium/package/authentication_config"
|
|
33
|
+
autoload :Preparation, "capsium/package/preparation"
|
|
17
34
|
autoload :Repository, "capsium/package/metadata_config"
|
|
35
|
+
autoload :ResolvedDependency, "capsium/package/dependency_resolver"
|
|
18
36
|
autoload :Resource, "capsium/package/manifest_config"
|
|
37
|
+
autoload :ResponseRewrite, "capsium/package/routes_config"
|
|
19
38
|
autoload :Route, "capsium/package/routes_config"
|
|
20
39
|
autoload :Routes, "capsium/package/routes"
|
|
21
40
|
autoload :RoutesConfig, "capsium/package/routes_config"
|
|
22
41
|
autoload :Security, "capsium/package/security"
|
|
23
42
|
autoload :SecurityConfig, "capsium/package/security_config"
|
|
24
43
|
autoload :SecurityData, "capsium/package/security_config"
|
|
44
|
+
autoload :Signer, "capsium/package/signer"
|
|
25
45
|
autoload :Storage, "capsium/package/storage"
|
|
26
46
|
autoload :StorageConfig, "capsium/package/storage_config"
|
|
27
47
|
autoload :StorageData, "capsium/package/storage_config"
|
|
48
|
+
autoload :Store, "capsium/package/store"
|
|
49
|
+
autoload :Testing, "capsium/package/testing"
|
|
50
|
+
autoload :UnsatisfiableDependencyError, "capsium/package/dependency_resolver"
|
|
28
51
|
autoload :Validator, "capsium/package/validator"
|
|
52
|
+
autoload :Verification, "capsium/package/verification"
|
|
53
|
+
autoload :Version, "capsium/package/version"
|
|
54
|
+
autoload :VersionRange, "capsium/package/version"
|
|
55
|
+
|
|
56
|
+
include Verification
|
|
57
|
+
include Composition
|
|
58
|
+
include Preparation
|
|
29
59
|
|
|
30
60
|
attr_reader :name, :path, :manifest, :metadata, :routes, :storage,
|
|
31
|
-
:security, :load_type
|
|
61
|
+
:security, :load_type, :resolved_dependencies, :authentication
|
|
32
62
|
|
|
33
63
|
MANIFEST_FILE = "manifest.json"
|
|
34
64
|
METADATA_FILE = "metadata.json"
|
|
35
65
|
STORAGE_FILE = "storage.json"
|
|
36
66
|
ROUTES_FILE = "routes.json"
|
|
37
67
|
SECURITY_FILE = "security.json"
|
|
68
|
+
SIGNATURE_FILE = "signature.sig"
|
|
69
|
+
AUTHENTICATION_FILE = "authentication.json"
|
|
38
70
|
CONTENT_DIR = "content"
|
|
39
71
|
DATA_DIR = "data"
|
|
40
72
|
|
|
41
|
-
|
|
73
|
+
# Loads a package directory or .cap file. Composite packages
|
|
74
|
+
# (metadata.dependencies, ARCHITECTURE.md section 4a) resolve against
|
|
75
|
+
# the package store given as `store` (directory path or Store) or via
|
|
76
|
+
# CAPSIUM_STORE. `dependency_chain` is internal: the ancestor GUIDs
|
|
77
|
+
# used for circular-dependency detection during recursive resolution.
|
|
78
|
+
def initialize(path, load_type: nil, decryption_key: nil, store: nil,
|
|
79
|
+
dependency_chain: [])
|
|
80
|
+
@decryption_key = decryption_key
|
|
42
81
|
@original_path = Pathname.new(path).expand_path
|
|
43
82
|
@path = prepare_package(@original_path).to_s
|
|
44
83
|
@load_type = load_type || determine_load_type(path)
|
|
45
84
|
create_package_structure
|
|
46
85
|
load_package
|
|
47
86
|
@name = metadata.name
|
|
87
|
+
@resolved_dependencies = resolve_dependencies(store, dependency_chain)
|
|
88
|
+
validate_dependency_references!
|
|
48
89
|
verify_integrity!
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def prepare_package(path)
|
|
52
|
-
return path if File.directory?(path)
|
|
53
|
-
|
|
54
|
-
if File.file?(path)
|
|
55
|
-
return decompress_cap_file(path) if File.extname(path) == ".cap"
|
|
56
|
-
|
|
57
|
-
raise Error, "The package must have a .cap extension"
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
raise Error, "Invalid package path: #{path}"
|
|
90
|
+
verify_signature!
|
|
61
91
|
end
|
|
62
92
|
|
|
63
93
|
def solidify
|
|
@@ -67,13 +97,6 @@ module Capsium
|
|
|
67
97
|
@storage.save_to_file unless @storage.empty?
|
|
68
98
|
end
|
|
69
99
|
|
|
70
|
-
def decompress_cap_file(file_path)
|
|
71
|
-
package_path = File.join(Dir.mktmpdir, package_stem(file_path))
|
|
72
|
-
FileUtils.mkdir_p(package_path)
|
|
73
|
-
Packager.new.unpack(file_path, package_path)
|
|
74
|
-
package_path
|
|
75
|
-
end
|
|
76
|
-
|
|
77
100
|
def load_package
|
|
78
101
|
# Mandatory
|
|
79
102
|
@metadata = Metadata.new(metadata_path)
|
|
@@ -83,16 +106,25 @@ module Capsium
|
|
|
83
106
|
@storage = Storage.new(storage_path)
|
|
84
107
|
@routes = Routes.new(routes_path, @manifest, @storage)
|
|
85
108
|
@security = Security.new(security_path)
|
|
109
|
+
@authentication = Authentication.new(authentication_path)
|
|
86
110
|
end
|
|
87
111
|
|
|
88
112
|
def cleanup
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
FileUtils.remove_entry(@path)
|
|
113
|
+
@resolved_dependencies.each { |dependency| dependency.package.cleanup }
|
|
114
|
+
FileUtils.remove_entry(@path) if @path != @original_path.to_s && File.directory?(@path)
|
|
92
115
|
end
|
|
93
116
|
|
|
94
|
-
def datasets
|
|
95
|
-
|
|
117
|
+
def datasets = storage.datasets
|
|
118
|
+
|
|
119
|
+
# The merged content view across the storage layers (ARCHITECTURE.md
|
|
120
|
+
# section 5a); exported_only gives the dependent-package view (section 4a).
|
|
121
|
+
# Resolved dependencies act as read-only layers below all own layers.
|
|
122
|
+
def merged_view(exported_only: false)
|
|
123
|
+
@merged_views ||= {}
|
|
124
|
+
@merged_views[exported_only] ||=
|
|
125
|
+
MergedView.new(@path, storage: @storage, manifest: @manifest,
|
|
126
|
+
dependency_views: dependency_views,
|
|
127
|
+
exported_only: exported_only)
|
|
96
128
|
end
|
|
97
129
|
|
|
98
130
|
# The .cap file this package was loaded from, or nil when loaded
|
|
@@ -102,55 +134,7 @@ module Capsium
|
|
|
102
134
|
end
|
|
103
135
|
|
|
104
136
|
def content_files
|
|
105
|
-
Dir.glob(File.join(content_path, "**", "*")).select
|
|
106
|
-
File.file?(file)
|
|
107
|
-
end
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
def determine_load_type(path)
|
|
111
|
-
return :directory if File.directory?(path)
|
|
112
|
-
return :cap_file if File.extname(path) == ".cap"
|
|
113
|
-
|
|
114
|
-
:unsaved
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
# Verifies the package against security.json (ARCHITECTURE.md section
|
|
118
|
-
# 6). Returns a list of typed errors; empty when no security.json is
|
|
119
|
-
# present or all checksums match.
|
|
120
|
-
def verify_integrity
|
|
121
|
-
return [] unless @security.present?
|
|
122
|
-
|
|
123
|
-
@security.verify(@path)
|
|
137
|
+
Dir.glob(File.join(content_path, "**", "*")).select { |file| File.file?(file) }
|
|
124
138
|
end
|
|
125
|
-
|
|
126
|
-
def verify_integrity!
|
|
127
|
-
@security.verify!(@path) if @security.present?
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
private
|
|
131
|
-
|
|
132
|
-
def package_stem(file_path)
|
|
133
|
-
File.basename(file_path, ".cap")
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
def create_package_structure
|
|
137
|
-
FileUtils.mkdir_p(@path)
|
|
138
|
-
FileUtils.mkdir_p(content_path)
|
|
139
|
-
FileUtils.mkdir_p(data_path)
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
def content_path = File.join(@path, CONTENT_DIR)
|
|
143
|
-
|
|
144
|
-
def data_path = File.join(@path, DATA_DIR)
|
|
145
|
-
|
|
146
|
-
def routes_path = File.join(@path, ROUTES_FILE)
|
|
147
|
-
|
|
148
|
-
def storage_path = File.join(@path, STORAGE_FILE)
|
|
149
|
-
|
|
150
|
-
def metadata_path = File.join(@path, METADATA_FILE)
|
|
151
|
-
|
|
152
|
-
def manifest_path = File.join(@path, MANIFEST_FILE)
|
|
153
|
-
|
|
154
|
-
def security_path = File.join(@path, SECURITY_FILE)
|
|
155
139
|
end
|
|
156
140
|
end
|
data/lib/capsium/packager.rb
CHANGED
|
@@ -33,7 +33,7 @@ module Capsium
|
|
|
33
33
|
|
|
34
34
|
Dir.mktmpdir do |dir|
|
|
35
35
|
FileUtils.cp_r("#{directory}/.", dir)
|
|
36
|
-
|
|
36
|
+
strip_security_artifacts(dir)
|
|
37
37
|
new_package = Package.new(dir)
|
|
38
38
|
new_package.solidify
|
|
39
39
|
generate_security(new_package)
|
|
@@ -68,6 +68,31 @@ module Capsium
|
|
|
68
68
|
end
|
|
69
69
|
end
|
|
70
70
|
|
|
71
|
+
# Unpacks a .cap into a temporary directory, yields it for read-only
|
|
72
|
+
# inspection and returns the block's value.
|
|
73
|
+
def with_unpacked_cap(cap_path)
|
|
74
|
+
Dir.mktmpdir do |dir|
|
|
75
|
+
unpack(cap_path, dir)
|
|
76
|
+
yield dir
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Unpacks a .cap into a temporary directory, yields it for
|
|
81
|
+
# modification, then recompresses the result back over cap_path.
|
|
82
|
+
# The modified package is loaded (verifying integrity and any
|
|
83
|
+
# declared signature) before recompressing.
|
|
84
|
+
def transform_cap(cap_path)
|
|
85
|
+
with_unpacked_cap(cap_path) do |dir|
|
|
86
|
+
yield dir
|
|
87
|
+
Dir.mktmpdir do |tmp|
|
|
88
|
+
tmp_cap = File.join(tmp, File.basename(cap_path))
|
|
89
|
+
compress_package(Package.new(dir), tmp_cap)
|
|
90
|
+
FileUtils.mv(tmp_cap, cap_path)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
cap_path
|
|
94
|
+
end
|
|
95
|
+
|
|
71
96
|
def relative_path_current(absolute_path)
|
|
72
97
|
Pathname.new(absolute_path).relative_path_from(Dir.pwd).to_s
|
|
73
98
|
end
|
|
@@ -94,5 +119,13 @@ module Capsium
|
|
|
94
119
|
security = Package::Security.generate(package.path)
|
|
95
120
|
security.save_to_file
|
|
96
121
|
end
|
|
122
|
+
|
|
123
|
+
# security.json is regenerated on pack; signing artifacts are dropped
|
|
124
|
+
# because signing is a post-pack step (`capsium package sign`).
|
|
125
|
+
def strip_security_artifacts(directory)
|
|
126
|
+
FileUtils.rm_f(File.join(directory, Package::SECURITY_FILE))
|
|
127
|
+
FileUtils.rm_f(File.join(directory, Package::SIGNATURE_FILE))
|
|
128
|
+
FileUtils.rm_f(File.join(directory, Package::Signer::PUBLIC_KEY_FILE))
|
|
129
|
+
end
|
|
97
130
|
end
|
|
98
131
|
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
|
|
5
|
+
module Capsium
|
|
6
|
+
class Reactor
|
|
7
|
+
# Request authentication and route authorization for the reactor
|
|
8
|
+
# (05x-authentication, ARCHITECTURE.md section 4b).
|
|
9
|
+
#
|
|
10
|
+
# When the package's authentication.json enables basicAuth, every
|
|
11
|
+
# route is challenged (401 + WWW-Authenticate) until valid htpasswd
|
|
12
|
+
# credentials arrive. When oauth2 is enabled, the login and callback
|
|
13
|
+
# endpoints run the authorization-code flow and establish a signed
|
|
14
|
+
# session cookie. Route-level accessControl is enforced after
|
|
15
|
+
# authentication: 401 when unauthenticated, 403 when the identity
|
|
16
|
+
# lacks a required role.
|
|
17
|
+
class Authenticator
|
|
18
|
+
LOGIN_PATH = "/auth/login"
|
|
19
|
+
|
|
20
|
+
attr_reader :authentication, :deploy
|
|
21
|
+
|
|
22
|
+
def initialize(authentication, deploy:, package_path:, base_url: nil,
|
|
23
|
+
state_file: nil)
|
|
24
|
+
@authentication = authentication
|
|
25
|
+
@deploy = deploy
|
|
26
|
+
@package_path = package_path
|
|
27
|
+
@base_url = base_url
|
|
28
|
+
@state_file = state_file
|
|
29
|
+
build_oauth2_flow if oauth2_enabled?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def enabled?
|
|
33
|
+
@authentication.enabled?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The paths this authenticator answers itself (OAuth2 login and
|
|
37
|
+
# callback), mounted by the reactor.
|
|
38
|
+
def endpoints
|
|
39
|
+
oauth2_enabled? ? [LOGIN_PATH, @authentication.oauth2.redirect_path] : []
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def endpoint?(path)
|
|
43
|
+
endpoints.include?(path)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def serve_endpoint(request, response)
|
|
47
|
+
if request.path == LOGIN_PATH
|
|
48
|
+
response.status = 302
|
|
49
|
+
response["Location"] = @oauth2_flow.authorization_url(request)
|
|
50
|
+
response.body = "Redirecting to the identity provider"
|
|
51
|
+
else
|
|
52
|
+
handle_callback(request, response)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The identity for a request — from the session cookie or Basic
|
|
57
|
+
# credentials — or nil. An identity always carries "roles"
|
|
58
|
+
# (possibly empty).
|
|
59
|
+
def authenticate(request)
|
|
60
|
+
identity = @session&.identity_from(request)
|
|
61
|
+
identity || authenticate_basic(request)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# 401, with a Basic challenge when basicAuth is enabled.
|
|
65
|
+
def challenge(response)
|
|
66
|
+
response.status = 401
|
|
67
|
+
response["Content-Type"] = "text/plain"
|
|
68
|
+
response["WWW-Authenticate"] = %(Basic realm="#{realm}") if basic_enabled?
|
|
69
|
+
response.body = "Unauthorized"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# :ok, :unauthenticated or :forbidden for a route's accessControl
|
|
73
|
+
# ({"roles": [...], "authenticationRequired": bool}).
|
|
74
|
+
def authorize(identity, access_control)
|
|
75
|
+
return :ok unless access_control
|
|
76
|
+
return :unauthenticated if unauthenticated?(identity, access_control)
|
|
77
|
+
return :forbidden unless roles_allowed?(identity, access_control["roles"])
|
|
78
|
+
|
|
79
|
+
:ok
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def unauthenticated?(identity, access_control)
|
|
85
|
+
access_control.fetch("authenticationRequired", true) && identity.nil?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def roles_allowed?(identity, roles)
|
|
89
|
+
return true if roles.nil? || roles.empty?
|
|
90
|
+
return false if identity.nil?
|
|
91
|
+
|
|
92
|
+
roles.intersect?(identity["roles"])
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def basic_enabled? = !!@authentication.basic_auth&.enabled?
|
|
96
|
+
|
|
97
|
+
def oauth2_enabled? = !!@authentication.oauth2&.enabled?
|
|
98
|
+
|
|
99
|
+
def realm = @authentication.basic_auth&.realm || "capsium"
|
|
100
|
+
|
|
101
|
+
def authenticate_basic(request)
|
|
102
|
+
return unless basic_enabled?
|
|
103
|
+
|
|
104
|
+
credentials = basic_credentials(request["Authorization"])
|
|
105
|
+
return unless credentials
|
|
106
|
+
|
|
107
|
+
username, password = credentials
|
|
108
|
+
return unless htpasswd.verify?(username, password)
|
|
109
|
+
|
|
110
|
+
{ "name" => username, "roles" => Array(@deploy.roles[username]) }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def basic_credentials(header)
|
|
114
|
+
return unless header&.start_with?("Basic ")
|
|
115
|
+
|
|
116
|
+
Base64.decode64(header.delete_prefix("Basic ")).split(":", 2)
|
|
117
|
+
rescue ArgumentError
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def htpasswd
|
|
122
|
+
@htpasswd ||= Htpasswd.new(htpasswd_path)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# deploy.json's basicAuth.passwdFile overrides the package's
|
|
126
|
+
# (reactor-side secret storage); the package's passwdFile is
|
|
127
|
+
# package-relative otherwise.
|
|
128
|
+
def htpasswd_path
|
|
129
|
+
@deploy.passwd_file ||
|
|
130
|
+
File.join(@package_path, @authentication.basic_auth.passwd_file.to_s)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def build_oauth2_flow
|
|
134
|
+
secret = @deploy.client_secret
|
|
135
|
+
if secret.nil? || secret.empty?
|
|
136
|
+
raise Error,
|
|
137
|
+
"oauth2 is enabled but no clientSecret is configured " \
|
|
138
|
+
"(deploy.json or CAPSIUM_DEPLOY; never in the package)"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
@session = Session.new(secret: @deploy.session_secret,
|
|
142
|
+
state_file: @state_file)
|
|
143
|
+
@oauth2_flow = OAuth2.new(@authentication.oauth2, client_secret: secret,
|
|
144
|
+
session: @session,
|
|
145
|
+
base_url: @base_url)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def handle_callback(request, response)
|
|
149
|
+
query = request.query
|
|
150
|
+
return respond_invalid_state(response) unless @oauth2_flow.valid_state?(query["state"])
|
|
151
|
+
|
|
152
|
+
userinfo = @oauth2_flow.complete(query["code"], request)
|
|
153
|
+
response.status = 302
|
|
154
|
+
response["Location"] = "/"
|
|
155
|
+
response["Set-Cookie"] = @session.cookie_for(identity_from(userinfo))
|
|
156
|
+
response.body = "Authenticated"
|
|
157
|
+
rescue OAuth2::FlowError => e
|
|
158
|
+
response.status = 502
|
|
159
|
+
response["Content-Type"] = "text/plain"
|
|
160
|
+
response.body = "OAuth2 provider error: #{e.message}"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def respond_invalid_state(response)
|
|
164
|
+
response.status = 401
|
|
165
|
+
response["Content-Type"] = "text/plain"
|
|
166
|
+
response.body = "Invalid OAuth2 state"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def identity_from(userinfo)
|
|
170
|
+
name = userinfo["email"] || userinfo["sub"] || userinfo["name"]
|
|
171
|
+
{
|
|
172
|
+
"sub" => userinfo["sub"] || userinfo["id"],
|
|
173
|
+
"email" => userinfo["email"],
|
|
174
|
+
"name" => userinfo["name"],
|
|
175
|
+
"roles" => Array(userinfo["roles"]) | Array(@deploy.roles[name])
|
|
176
|
+
}.compact
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Capsium
|
|
6
|
+
class Reactor
|
|
7
|
+
# Reactor-side deployment configuration (deploy.json): everything
|
|
8
|
+
# that must NOT ship in the package — OAuth2 client secrets, the
|
|
9
|
+
# session signing secret, role assignments, an alternate htpasswd
|
|
10
|
+
# file, the public base URL. Shape:
|
|
11
|
+
#
|
|
12
|
+
# {
|
|
13
|
+
# "baseUrl": "http://localhost:8864",
|
|
14
|
+
# "authentication": {
|
|
15
|
+
# "basicAuth": { "passwdFile": "/secure/.htpasswd" },
|
|
16
|
+
# "oauth2": { "clientSecret": "..." },
|
|
17
|
+
# "sessionSecret": "...",
|
|
18
|
+
# "roles": { "alice": ["admin"] }
|
|
19
|
+
# }
|
|
20
|
+
# }
|
|
21
|
+
#
|
|
22
|
+
# Loaded from an explicit path, an already-parsed Hash, or the
|
|
23
|
+
# CAPSIUM_DEPLOY environment variable; empty when unconfigured.
|
|
24
|
+
class Deploy
|
|
25
|
+
FILE = "deploy.json"
|
|
26
|
+
ENV_VAR = "CAPSIUM_DEPLOY"
|
|
27
|
+
|
|
28
|
+
attr_reader :config
|
|
29
|
+
|
|
30
|
+
def self.load(source)
|
|
31
|
+
return new(source) if source.is_a?(Hash)
|
|
32
|
+
|
|
33
|
+
path = source || ENV.fetch(ENV_VAR, nil)
|
|
34
|
+
return new({}) if path.nil? || path.to_s.empty?
|
|
35
|
+
raise Error, "deploy configuration not found: #{path}" unless File.file?(path)
|
|
36
|
+
|
|
37
|
+
new(JSON.parse(File.read(path)))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def initialize(config)
|
|
41
|
+
@config = config
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def authentication
|
|
45
|
+
config["authentication"] || {}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def base_url
|
|
49
|
+
config["baseUrl"]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def client_secret
|
|
53
|
+
authentication.dig("oauth2", "clientSecret")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def session_secret
|
|
57
|
+
authentication["sessionSecret"]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def passwd_file
|
|
61
|
+
authentication.dig("basicAuth", "passwdFile")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Role assignments keyed by identity name (basic-auth username,
|
|
65
|
+
# OAuth2 email or subject): {"alice": ["admin", "user"]}.
|
|
66
|
+
def roles
|
|
67
|
+
authentication["roles"] || {}
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "bcrypt"
|
|
5
|
+
require "digest"
|
|
6
|
+
require "openssl"
|
|
7
|
+
|
|
8
|
+
module Capsium
|
|
9
|
+
class Reactor
|
|
10
|
+
# Apache htpasswd verification (05x-authentication). Supported hash
|
|
11
|
+
# formats:
|
|
12
|
+
# - bcrypt ($2a$/$2b$/$2y$, `htpasswd -B`) via the bcrypt gem
|
|
13
|
+
# - Apache apr1 MD5 ($apr1$, `htpasswd -m`) and md5-crypt ($1$),
|
|
14
|
+
# pure-Ruby md5-crypt
|
|
15
|
+
# - unsalted SHA-1 ({SHA}, `htpasswd -s`)
|
|
16
|
+
# - anything else (DES, $5$/$6$) via the platform's crypt(3) —
|
|
17
|
+
# support depends on the OS
|
|
18
|
+
class Htpasswd
|
|
19
|
+
# md5-crypt as deployed by Apache htpasswd, OpenSSL and glibc
|
|
20
|
+
# ($1$ and $apr1$ magics; identical except for the magic string).
|
|
21
|
+
# NOTE: Poul-Henning Kamp's original FreeBSD implementation mixed
|
|
22
|
+
# the magic in at the % 7 step where all deployed implementations
|
|
23
|
+
# mix in the password (see apr_md5.c, glibc crypt-md5.c).
|
|
24
|
+
module Md5Crypt
|
|
25
|
+
ITOA64 = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
26
|
+
DIGEST_GROUPS = [[0, 6, 12], [1, 7, 13], [2, 8, 14], [3, 9, 15], [4, 10, 5]].freeze
|
|
27
|
+
|
|
28
|
+
def self.verify(hash, password)
|
|
29
|
+
_prefix, magic, salt, = hash.split("$", 4)
|
|
30
|
+
return false if salt.nil? || salt.empty?
|
|
31
|
+
|
|
32
|
+
Reactor.secure_compare(digest(password, salt, "$#{magic}$"), hash)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.digest(password, salt, magic)
|
|
36
|
+
final = initial_digest(password, salt, magic)
|
|
37
|
+
1000.times do |i|
|
|
38
|
+
final = stretch_digest(i, password, salt, final)
|
|
39
|
+
end
|
|
40
|
+
"#{magic}#{salt}$#{to64(final)}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.initial_digest(password, salt, magic)
|
|
44
|
+
inner = Digest::MD5.digest(password + salt + password)
|
|
45
|
+
context = Digest::MD5.new
|
|
46
|
+
context << password << magic << salt
|
|
47
|
+
length = password.length
|
|
48
|
+
while length.positive?
|
|
49
|
+
context << inner[0, [16, length].min]
|
|
50
|
+
length -= 16
|
|
51
|
+
end
|
|
52
|
+
length = password.length
|
|
53
|
+
while length.positive?
|
|
54
|
+
context << (length.odd? ? "\0" : password[0])
|
|
55
|
+
length >>= 1
|
|
56
|
+
end
|
|
57
|
+
context.digest
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.stretch_digest(iteration, password, salt, final)
|
|
61
|
+
context = Digest::MD5.new
|
|
62
|
+
context << (iteration.odd? ? password : final)
|
|
63
|
+
context << salt unless (iteration % 3).zero?
|
|
64
|
+
context << password unless (iteration % 7).zero?
|
|
65
|
+
context << (iteration.odd? ? final : password)
|
|
66
|
+
context.digest
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The custom base-64 of md5-crypt: digest triples permuted, first
|
|
70
|
+
# index to the high bits, 22 characters total.
|
|
71
|
+
def self.to64(final)
|
|
72
|
+
encoded = +""
|
|
73
|
+
DIGEST_GROUPS.each do |a, b, c|
|
|
74
|
+
value = final[c].ord | (final[b].ord << 8) | (final[a].ord << 16)
|
|
75
|
+
4.times do
|
|
76
|
+
encoded << ITOA64[value & 0x3f]
|
|
77
|
+
value >>= 6
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
value = final[11].ord
|
|
81
|
+
2.times do
|
|
82
|
+
encoded << ITOA64[value & 0x3f]
|
|
83
|
+
value >>= 6
|
|
84
|
+
end
|
|
85
|
+
encoded
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
attr_reader :path
|
|
90
|
+
|
|
91
|
+
def initialize(path)
|
|
92
|
+
raise Error, "htpasswd file not found: #{path}" unless File.file?(path)
|
|
93
|
+
|
|
94
|
+
@path = path
|
|
95
|
+
@entries = parse(path)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def usernames
|
|
99
|
+
@entries.keys
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def verify?(username, password)
|
|
103
|
+
hash = @entries[username]
|
|
104
|
+
return false unless hash
|
|
105
|
+
|
|
106
|
+
verify_hash(hash, password)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def parse(path)
|
|
112
|
+
File.readlines(path, chomp: true).each_with_object({}) do |line, entries|
|
|
113
|
+
next if line.strip.empty? || line.start_with?("#")
|
|
114
|
+
|
|
115
|
+
username, hash = line.split(":", 2)
|
|
116
|
+
entries[username] = hash
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def verify_hash(hash, password)
|
|
121
|
+
case hash
|
|
122
|
+
when /\A\$2[aby]\$/ then verify_bcrypt(hash, password)
|
|
123
|
+
when /\A\$(?:apr1|1)\$/ then Md5Crypt.verify(hash, password)
|
|
124
|
+
when /\A\{SHA\}/ then verify_sha1(hash, password)
|
|
125
|
+
else verify_crypt(hash, password)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The bcrypt gem predates htpasswd's $2y$ tag; the variants are
|
|
130
|
+
# algorithmically identical, normalized here.
|
|
131
|
+
def verify_bcrypt(hash, password)
|
|
132
|
+
BCrypt::Password.new(hash.sub(/\A\$2y\$/, "$2a$")) == password
|
|
133
|
+
rescue BCrypt::Errors::InvalidHash
|
|
134
|
+
false
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def verify_sha1(hash, password)
|
|
138
|
+
digest = "{SHA}#{Base64.strict_encode64(Digest::SHA1.digest(password))}"
|
|
139
|
+
Reactor.secure_compare(digest, hash)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def verify_crypt(hash, password)
|
|
143
|
+
computed = password.crypt(hash)
|
|
144
|
+
computed && Reactor.secure_compare(computed, hash)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Constant-time string comparison (length-check first).
|
|
149
|
+
def self.secure_compare(own, theirs)
|
|
150
|
+
own.bytesize == theirs.bytesize &&
|
|
151
|
+
OpenSSL.fixed_length_secure_compare(own, theirs)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -64,8 +64,11 @@ module Capsium
|
|
|
64
64
|
entry = {
|
|
65
65
|
package: package.name,
|
|
66
66
|
valid: errors.empty?,
|
|
67
|
-
lastChecked: Time.now.utc.iso8601
|
|
67
|
+
lastChecked: Time.now.utc.iso8601,
|
|
68
|
+
signed: package.signed?,
|
|
69
|
+
encrypted: package.encrypted?
|
|
68
70
|
}
|
|
71
|
+
entry[:signatureValid] = package.verify_signature if package.signed?
|
|
69
72
|
entry[:reason] = errors.map(&:message).join("; ") unless errors.empty?
|
|
70
73
|
{ contentValidity: [entry] }
|
|
71
74
|
end
|