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.
Files changed (80) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +12 -0
  3. data/CHANGELOG.md +125 -0
  4. data/README.adoc +332 -5
  5. data/capsium.gemspec +1 -0
  6. data/lib/capsium/cli/formatting.rb +27 -0
  7. data/lib/capsium/cli/package.rb +68 -6
  8. data/lib/capsium/cli/reactor.rb +9 -1
  9. data/lib/capsium/cli.rb +1 -0
  10. data/lib/capsium/package/authentication.rb +38 -0
  11. data/lib/capsium/package/authentication_config.rb +78 -0
  12. data/lib/capsium/package/cipher.rb +197 -0
  13. data/lib/capsium/package/composition.rb +77 -0
  14. data/lib/capsium/package/dependency_resolver.rb +61 -0
  15. data/lib/capsium/package/encryption_config.rb +38 -0
  16. data/lib/capsium/package/merged_view.rb +168 -0
  17. data/lib/capsium/package/preparation.rb +81 -0
  18. data/lib/capsium/package/routes_config.rb +60 -6
  19. data/lib/capsium/package/security.rb +21 -4
  20. data/lib/capsium/package/signer.rb +201 -0
  21. data/lib/capsium/package/storage_config.rb +29 -1
  22. data/lib/capsium/package/store.rb +109 -0
  23. data/lib/capsium/package/testing/config_test.rb +70 -0
  24. data/lib/capsium/package/testing/context.rb +11 -0
  25. data/lib/capsium/package/testing/data_validation_test.rb +63 -0
  26. data/lib/capsium/package/testing/file_test.rb +45 -0
  27. data/lib/capsium/package/testing/report.rb +33 -0
  28. data/lib/capsium/package/testing/route_test.rb +64 -0
  29. data/lib/capsium/package/testing/test_case.rb +60 -0
  30. data/lib/capsium/package/testing/test_suite.rb +86 -0
  31. data/lib/capsium/package/testing.rb +20 -0
  32. data/lib/capsium/package/validator.rb +14 -3
  33. data/lib/capsium/package/verification.rb +32 -0
  34. data/lib/capsium/package/version.rb +204 -0
  35. data/lib/capsium/package.rb +59 -75
  36. data/lib/capsium/packager.rb +34 -1
  37. data/lib/capsium/reactor/authenticator.rb +180 -0
  38. data/lib/capsium/reactor/deploy.rb +71 -0
  39. data/lib/capsium/reactor/htpasswd.rb +154 -0
  40. data/lib/capsium/reactor/introspection.rb +4 -1
  41. data/lib/capsium/reactor/oauth2.rb +106 -0
  42. data/lib/capsium/reactor/serving.rb +75 -0
  43. data/lib/capsium/reactor/session.rb +82 -0
  44. data/lib/capsium/reactor.rb +43 -41
  45. data/lib/capsium/thor_ext.rb +1 -1
  46. data/lib/capsium/version.rb +1 -1
  47. data/sig/capsium/package/authentication.rbs +23 -0
  48. data/sig/capsium/package/authentication_config.rbs +71 -0
  49. data/sig/capsium/package/cipher.rbs +51 -0
  50. data/sig/capsium/package/composition.rbs +20 -0
  51. data/sig/capsium/package/dependency_resolver.rbs +49 -0
  52. data/sig/capsium/package/encryption_config.rbs +35 -0
  53. data/sig/capsium/package/merged_view.rbs +46 -0
  54. data/sig/capsium/package/preparation.rbs +23 -0
  55. data/sig/capsium/package/routes_config.rbs +36 -3
  56. data/sig/capsium/package/security.rbs +9 -2
  57. data/sig/capsium/package/signer.rbs +59 -0
  58. data/sig/capsium/package/storage_config.rbs +26 -2
  59. data/sig/capsium/package/store.rbs +37 -0
  60. data/sig/capsium/package/testing/config_test.rbs +21 -0
  61. data/sig/capsium/package/testing/context.rbs +14 -0
  62. data/sig/capsium/package/testing/data_validation_test.rbs +22 -0
  63. data/sig/capsium/package/testing/file_test.rbs +17 -0
  64. data/sig/capsium/package/testing/report.rbs +20 -0
  65. data/sig/capsium/package/testing/route_test.rbs +22 -0
  66. data/sig/capsium/package/testing/test_case.rbs +41 -0
  67. data/sig/capsium/package/testing/test_suite.rbs +20 -0
  68. data/sig/capsium/package/testing.rbs +7 -0
  69. data/sig/capsium/package/verification.rbs +24 -0
  70. data/sig/capsium/package/version.rbs +39 -0
  71. data/sig/capsium/package.rbs +25 -17
  72. data/sig/capsium/packager.rbs +9 -0
  73. data/sig/capsium/reactor/authenticator.rbs +40 -0
  74. data/sig/capsium/reactor/deploy.rbs +34 -0
  75. data/sig/capsium/reactor/htpasswd.rbs +36 -0
  76. data/sig/capsium/reactor/oauth2.rbs +31 -0
  77. data/sig/capsium/reactor/serving.rbs +22 -0
  78. data/sig/capsium/reactor/session.rbs +36 -0
  79. data/sig/capsium/reactor.rbs +6 -1
  80. metadata +70 -1
@@ -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
- def initialize(path, load_type: nil)
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
- end
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
- return unless @path != @original_path.to_s && File.directory?(@path)
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
- storage.datasets
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 do |file|
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
@@ -33,7 +33,7 @@ module Capsium
33
33
 
34
34
  Dir.mktmpdir do |dir|
35
35
  FileUtils.cp_r("#{directory}/.", dir)
36
- FileUtils.rm_f(File.join(dir, Package::SECURITY_FILE))
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