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
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "securerandom"
6
+ require "uri"
7
+
8
+ module Capsium
9
+ class Reactor
10
+ # OAuth2 authorization-code flow client (05x-authentication), used by
11
+ # the Authenticator. Talks to the provider over net/http; the client
12
+ # secret never comes from the package. State is a random nonce signed
13
+ # with the session HMAC key (CSRF protection, self-contained).
14
+ class OAuth2
15
+ # The token exchange or userinfo fetch failed at the provider.
16
+ class FlowError < Capsium::Error; end
17
+
18
+ GRANT_TYPE = "authorization_code"
19
+
20
+ attr_reader :config
21
+
22
+ def initialize(config, client_secret:, session:, base_url: nil)
23
+ @config = config
24
+ @client_secret = client_secret
25
+ @session = session
26
+ @base_url = base_url
27
+ end
28
+
29
+ # The provider URL the login endpoint redirects the browser to.
30
+ def authorization_url(request)
31
+ params = {
32
+ "response_type" => "code",
33
+ "client_id" => @config.client_id,
34
+ "redirect_uri" => callback_uri(request),
35
+ "state" => generate_state
36
+ }
37
+ params["scope"] = @config.scopes.join(" ") unless @config.scopes.empty?
38
+ "#{@config.authorization_url}?#{URI.encode_www_form(params)}"
39
+ end
40
+
41
+ def valid_state?(state)
42
+ nonce, signature = state.to_s.split(".", 2)
43
+ return false if nonce.nil? || nonce.empty? || signature.nil?
44
+
45
+ Reactor.secure_compare(@session.sign(nonce), signature)
46
+ end
47
+
48
+ # Exchanges the authorization code and fetches the userinfo claims.
49
+ # Returns the raw userinfo hash; raises FlowError on provider
50
+ # errors.
51
+ def complete(code, request)
52
+ userinfo(exchange_code(code, callback_uri(request)))
53
+ end
54
+
55
+ def callback_uri(request)
56
+ "#{base_url_for(request)}#{@config.redirect_path}"
57
+ end
58
+
59
+ private
60
+
61
+ def generate_state
62
+ nonce = SecureRandom.hex(16)
63
+ "#{nonce}.#{@session.sign(nonce)}"
64
+ end
65
+
66
+ def base_url_for(request)
67
+ @base_url || "http://#{request['Host']}"
68
+ end
69
+
70
+ def exchange_code(code, redirect_uri)
71
+ response = Net::HTTP.post_form(
72
+ URI(@config.token_url),
73
+ "grant_type" => GRANT_TYPE, "code" => code,
74
+ "redirect_uri" => redirect_uri, "client_id" => @config.client_id,
75
+ "client_secret" => @client_secret
76
+ )
77
+ unless response.is_a?(Net::HTTPSuccess)
78
+ raise FlowError, "token exchange failed (HTTP #{response.code})"
79
+ end
80
+
81
+ JSON.parse(response.body).fetch("access_token") do
82
+ raise FlowError, "token response without access_token"
83
+ end
84
+ rescue JSON::ParserError
85
+ raise FlowError, "token response was not JSON"
86
+ end
87
+
88
+ def userinfo(access_token)
89
+ uri = URI(@config.userinfo_url)
90
+ response = Net::HTTP.start(uri.host, uri.port,
91
+ use_ssl: uri.scheme == "https") do |http|
92
+ http.get(uri.request_uri,
93
+ "authorization" => "Bearer #{access_token}",
94
+ "accept" => "application/json")
95
+ end
96
+ unless response.is_a?(Net::HTTPSuccess)
97
+ raise FlowError, "userinfo request failed (HTTP #{response.code})"
98
+ end
99
+
100
+ JSON.parse(response.body)
101
+ rescue JSON::ParserError
102
+ raise FlowError, "userinfo response was not JSON"
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "marcel"
4
+ require "pathname"
5
+
6
+ module Capsium
7
+ class Reactor
8
+ # Serving of resolved routes (static files through the merged view,
9
+ # datasets, handler-route 501s), mixed into Reactor. Includes route
10
+ # inheritance processing per 05x-routing.
11
+ module Serving
12
+ private
13
+
14
+ def serve_route(route, response)
15
+ case route.kind
16
+ when :dataset then serve_dataset(route.dataset, response)
17
+ when :resource then serve_file(route, response)
18
+ else respond_not_implemented(response)
19
+ end
20
+ end
21
+
22
+ def serve_file(route, response)
23
+ content_path = @merged_view.resolve(route.resource)
24
+ return respond_not_found(response) unless content_path
25
+
26
+ body, headers = inherited_processing(route, File.read(content_path),
27
+ headers_for(route))
28
+ response.status = 200
29
+ response["Content-Type"] = content_type_for(route, content_path)
30
+ headers.each { |name, value| response[name] = value }
31
+ response.body = body
32
+ end
33
+
34
+ # Route inheritance processing (05x-routing section "Route
35
+ # Inheritance"): responseRewrite replaces the body and/or overrides
36
+ # headers; responseHeaders are merged over the served headers.
37
+ # requestHeaders are parsed and exposed on the route for forwarding
38
+ # reactors; this reactor serves statically without an upstream, so
39
+ # they do not alter its responses.
40
+ def inherited_processing(route, body, headers)
41
+ rewrite = route.response_rewrite
42
+ if rewrite
43
+ body = rewrite.body unless rewrite.body.nil?
44
+ headers = headers.merge(rewrite.headers) if rewrite.headers
45
+ end
46
+ headers = headers.merge(route.response_headers) if route.response_headers
47
+ [body, headers]
48
+ end
49
+
50
+ # The route's declared MIME type, detected from the resolved file
51
+ # for resources the manifest does not list (dependency content
52
+ # reached through inheritance, layer-only files).
53
+ def content_type_for(route, content_path)
54
+ route.mime(@package.manifest) ||
55
+ Marcel::MimeType.for(Pathname.new(content_path),
56
+ name: File.basename(content_path))
57
+ end
58
+
59
+ def headers_for(route)
60
+ route.headers || (@cache_control ? { "Cache-Control" => @cache_control } : {})
61
+ end
62
+
63
+ def serve_dataset(dataset_name, response)
64
+ dataset = @package.storage.dataset(dataset_name)
65
+ if dataset
66
+ response.status = 200
67
+ response["Content-Type"] = "application/json"
68
+ response.body = JSON.generate(dataset.data)
69
+ else
70
+ respond_not_found(response)
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "openssl"
6
+ require "securerandom"
7
+
8
+ module Capsium
9
+ class Reactor
10
+ # HMAC-SHA256 signed session cookies (05x-authentication: "Session
11
+ # via signed cookie"). The cookie value is
12
+ # "base64url(JSON payload).hmac hex" — signed, not encrypted, so it
13
+ # carries only the identity claims. The signing secret comes from
14
+ # deploy.json (sessionSecret) or is generated and persisted
15
+ # reactor-side (never in the package).
16
+ class Session
17
+ COOKIE_NAME = "capsium_session"
18
+ SECRET_BYTES = 32
19
+
20
+ def self.hmac(secret, data)
21
+ OpenSSL::HMAC.hexdigest("SHA256", secret, data)
22
+ end
23
+
24
+ attr_reader :secret
25
+
26
+ def initialize(secret: nil, state_file: nil)
27
+ @secret = secret || self.class.load_or_generate_secret(state_file)
28
+ end
29
+
30
+ # HMAC-SHA256 of data with the session secret (hex).
31
+ def sign(data)
32
+ self.class.hmac(@secret, data)
33
+ end
34
+
35
+ # The persisted reactor-side secret: loaded from state_file when
36
+ # present, generated and written (mode 0600) otherwise.
37
+ def self.load_or_generate_secret(state_file)
38
+ raise Error, "session secret or state_file is required" unless state_file
39
+
40
+ return File.read(state_file).strip if File.file?(state_file)
41
+
42
+ secret = SecureRandom.hex(SECRET_BYTES)
43
+ File.write(state_file, secret, perm: 0o600)
44
+ secret
45
+ end
46
+
47
+ def encode(payload)
48
+ data = Base64.urlsafe_encode64(JSON.generate(payload), padding: false)
49
+ "#{data}.#{self.class.hmac(@secret, data)}"
50
+ end
51
+
52
+ # The verified payload, or nil when the cookie is missing or the
53
+ # signature does not match.
54
+ def decode(cookie_value)
55
+ data, signature = cookie_value.to_s.split(".", 2)
56
+ return if data.nil? || signature.nil?
57
+
58
+ expected = self.class.hmac(@secret, data)
59
+ return unless Reactor.secure_compare(expected, signature)
60
+
61
+ JSON.parse(Base64.urlsafe_decode64(data))
62
+ rescue JSON::ParserError, ArgumentError
63
+ nil
64
+ end
65
+
66
+ # The identity for a request's Cookie header, or nil.
67
+ def identity_from(request)
68
+ cookie = request["Cookie"].to_s.split(/;\s*/).find do |part|
69
+ part.start_with?("#{COOKIE_NAME}=")
70
+ end
71
+ return unless cookie
72
+
73
+ decode(cookie.delete_prefix("#{COOKIE_NAME}="))
74
+ end
75
+
76
+ # A Set-Cookie value establishing the identity session.
77
+ def cookie_for(identity)
78
+ "#{COOKIE_NAME}=#{encode(identity)}; Path=/; HttpOnly; SameSite=Lax"
79
+ end
80
+ end
81
+ end
82
+ end
@@ -2,24 +2,36 @@
2
2
 
3
3
  require "json"
4
4
  require "listen"
5
+ require "tmpdir"
5
6
  require "webrick"
6
7
 
7
8
  module Capsium
8
9
  class Reactor
10
+ autoload :Authenticator, "capsium/reactor/authenticator"
11
+ autoload :Deploy, "capsium/reactor/deploy"
12
+ autoload :Htpasswd, "capsium/reactor/htpasswd"
9
13
  autoload :Introspection, "capsium/reactor/introspection"
14
+ autoload :OAuth2, "capsium/reactor/oauth2"
15
+ autoload :Serving, "capsium/reactor/serving"
16
+ autoload :Session, "capsium/reactor/session"
17
+
18
+ include Serving
10
19
 
11
20
  DEFAULT_PORT = 8864
12
21
  DEFAULT_CACHE_CONTROL = "public, max-age=31536000"
13
22
 
14
23
  attr_reader :package, :package_path, :routes, :port, :cache_control,
15
- :server, :server_thread, :introspection
24
+ :server, :server_thread, :introspection, :authenticator
16
25
 
17
26
  def initialize(package:, port: DEFAULT_PORT,
18
- cache_control: DEFAULT_CACHE_CONTROL, do_not_listen: false)
19
- @package = package.is_a?(String) ? Package.new(package) : package
27
+ cache_control: DEFAULT_CACHE_CONTROL, do_not_listen: false,
28
+ store: nil, deploy: nil)
29
+ @package = package.is_a?(String) ? Package.new(package, store: store) : package
20
30
  @package_path = @package.path
31
+ @store = store
21
32
  @port = port
22
33
  @cache_control = cache_control
34
+ @deploy_config = Deploy.load(deploy)
23
35
  setup_server(do_not_listen)
24
36
  load_state
25
37
  mount_routes
@@ -32,14 +44,29 @@ module Capsium
32
44
  end
33
45
 
34
46
  def handle_request(request, response)
47
+ if @authenticator.endpoint?(request.path)
48
+ @authenticator.serve_endpoint(request, response)
49
+ return
50
+ end
51
+
52
+ identity = @authenticator.authenticate(request)
53
+ return @authenticator.challenge(response) if @authenticator.enabled? && identity.nil?
35
54
  return serve_introspection(request, response) if @introspection.endpoint?(request.path)
36
55
 
37
56
  route = @routes.resolve(request.path)
38
- route ? serve_route(route, response) : respond_not_found(response)
57
+ return respond_not_found(response) unless route
58
+
59
+ case @authenticator.authorize(identity, route.access_control)
60
+ when :unauthenticated then return @authenticator.challenge(response)
61
+ when :forbidden then return respond_forbidden(response)
62
+ end
63
+
64
+ serve_route(route, response)
39
65
  end
40
66
 
41
67
  def mount_routes
42
- paths = @routes.config.routes.map(&:path) + Introspection::PATHS
68
+ paths = @routes.config.routes.map(&:serving_path) +
69
+ Introspection::PATHS + @authenticator.endpoints
43
70
  paths.each do |path|
44
71
  @server.mount_proc(path.to_s) { |req, res| handle_request(req, res) }
45
72
  end
@@ -87,13 +114,21 @@ module Capsium
87
114
  end
88
115
 
89
116
  def load_package
90
- @package = Package.new(@package_path)
117
+ @package = Package.new(@package_path, store: @store)
91
118
  load_state
92
119
  end
93
120
 
94
121
  def load_state
95
122
  @routes = @package.routes
123
+ @merged_view = @package.merged_view
96
124
  @introspection = Introspection.new(@package)
125
+ @authenticator = Authenticator.new(
126
+ @package.authentication,
127
+ deploy: @deploy_config,
128
+ package_path: @package.path,
129
+ base_url: @deploy_config.base_url,
130
+ state_file: File.join(Dir.tmpdir, "capsium-#{@package.name}-session-secret")
131
+ )
97
132
  end
98
133
 
99
134
  def serve_introspection(request, response)
@@ -104,43 +139,10 @@ module Capsium
104
139
  response.body = JSON.generate(@introspection.report_for(request.path))
105
140
  end
106
141
 
107
- def serve_route(route, response)
108
- case route.kind
109
- when :dataset then serve_dataset(route.dataset, response)
110
- when :resource then serve_file(route, response)
111
- else respond_not_implemented(response)
112
- end
113
- end
114
-
115
- def serve_file(route, response)
116
- content_path = route.fs_path(@package.path)
117
- if content_path && File.exist?(content_path)
118
- response.status = 200
119
- response["Content-Type"] = route.mime(@package.manifest) || "application/octet-stream"
120
- headers_for(route).each { |name, value| response[name] = value }
121
- response.body = File.read(content_path)
122
- else
123
- respond_not_found(response)
124
- end
125
- end
126
-
127
- def headers_for(route)
128
- route.headers || (@cache_control ? { "Cache-Control" => @cache_control } : {})
129
- end
130
-
131
- def serve_dataset(dataset_name, response)
132
- dataset = @package.storage.dataset(dataset_name)
133
- if dataset
134
- response.status = 200
135
- response["Content-Type"] = "application/json"
136
- response.body = JSON.generate(dataset.data)
137
- else
138
- respond_not_found(response)
139
- end
140
- end
141
-
142
142
  def respond_not_found(response) = respond_text(response, 404, "Not Found")
143
143
 
144
+ def respond_forbidden(response) = respond_text(response, 403, "Forbidden")
145
+
144
146
  def respond_not_implemented(response) = respond_text(response, 501, "Not Implemented")
145
147
 
146
148
  def respond_method_not_allowed(response) = respond_text(response, 405, "Method Not Allowed")
@@ -60,7 +60,7 @@ module Capsium
60
60
  return if error.is_a?(Errno::EPIPE)
61
61
  raise if ENV["VERBOSE"] || !config.fetch(:exit_on_failure, true)
62
62
 
63
- message = error.message.to_s
63
+ message = +error.message.to_s
64
64
  message.prepend("[#{error.class}] ") if message.empty? || !error.is_a?(Thor::Error)
65
65
 
66
66
  config[:shell]&.say_error(message, :red)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Capsium
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -0,0 +1,23 @@
1
+ module Capsium
2
+ class Package
3
+ # Loads a package's authentication.json (ARCHITECTURE.md section 4b).
4
+ class Authentication
5
+ attr_reader path: String
6
+ attr_reader config: AuthenticationConfig?
7
+
8
+ def initialize: (String path) -> void
9
+
10
+ def present?: () -> bool
11
+
12
+ def basic_auth: () -> BasicAuthConfig?
13
+
14
+ def oauth2: () -> OAuth2Config?
15
+
16
+ # Whether any authentication method is enabled.
17
+ def enabled?: () -> bool
18
+
19
+ def to_json: (*untyped args) -> String
20
+ def to_hash: () -> Hash[String, untyped]
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,71 @@
1
+ module Capsium
2
+ class Package
3
+ # The "basicAuth" object of authentication.json (ARCHITECTURE.md
4
+ # section 4b): Apache-style Basic authentication verified against an
5
+ # htpasswd file.
6
+ class BasicAuthConfig
7
+ attr_accessor enabled: bool
8
+ attr_accessor passwd_file: String?
9
+ attr_accessor realm: String
10
+
11
+ def initialize: (?enabled: bool enabled, ?passwd_file: String? passwd_file,
12
+ ?realm: String? realm) -> void
13
+
14
+ def enabled?: () -> bool
15
+
16
+ def to_json: (*untyped args) -> String
17
+ def to_hash: () -> Hash[String, untyped]
18
+ end
19
+
20
+ # The "oauth2" object of authentication.json: an OAuth2
21
+ # authorization-code flow. The client secret is NEVER read from the
22
+ # package — it comes from deploy.json or reactor configuration.
23
+ class OAuth2Config
24
+ attr_accessor enabled: bool
25
+ attr_accessor provider: String?
26
+ attr_accessor client_id: String?
27
+ attr_accessor authorization_url: String?
28
+ attr_accessor token_url: String?
29
+ attr_accessor userinfo_url: String?
30
+ attr_accessor redirect_path: String
31
+ attr_accessor scopes: Array[String]
32
+
33
+ def initialize: (?enabled: bool enabled, ?provider: String? provider,
34
+ ?client_id: String? client_id,
35
+ ?authorization_url: String? authorization_url,
36
+ ?token_url: String? token_url,
37
+ ?userinfo_url: String? userinfo_url,
38
+ ?redirect_path: String? redirect_path,
39
+ ?scopes: Array[String] scopes) -> void
40
+
41
+ def enabled?: () -> bool
42
+
43
+ def to_json: (*untyped args) -> String
44
+ def to_hash: () -> Hash[String, untyped]
45
+ end
46
+
47
+ # The "authentication" object of authentication.json.
48
+ class AuthenticationData
49
+ attr_accessor basic_auth: BasicAuthConfig?
50
+ attr_accessor oauth2: OAuth2Config?
51
+
52
+ def initialize: (?basic_auth: BasicAuthConfig? basic_auth,
53
+ ?oauth2: OAuth2Config? oauth2) -> void
54
+
55
+ def to_json: (*untyped args) -> String
56
+ def to_hash: () -> Hash[String, untyped]
57
+ end
58
+
59
+ # Canonical authentication.json model (ARCHITECTURE.md section 4b).
60
+ class AuthenticationConfig
61
+ attr_accessor authentication: AuthenticationData?
62
+
63
+ def initialize: (?authentication: AuthenticationData? authentication) -> void
64
+
65
+ def self.from_json: (String json) -> instance
66
+
67
+ def to_json: (*untyped args) -> String
68
+ def to_hash: () -> Hash[String, untyped]
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,51 @@
1
+ module Capsium
2
+ class Package
3
+ # Encrypts and decrypts whole Capsium packages (05x-packaging
4
+ # "Encryption", 05x-security "Encrypted information"). An encrypted
5
+ # .cap is a zip containing metadata.json (cleartext), signature.json
6
+ # (the encryption envelope) and package.enc (AES-256-GCM of the inner
7
+ # plaintext .cap zip). The random DEK is wrapped with the recipient's
8
+ # RSA public key using OAEP with SHA-256. OCB/OpenPGP are out of
9
+ # scope.
10
+ class Cipher
11
+ ALGORITHM: String
12
+ KEY_MANAGEMENT: String
13
+ CIPHER_NAME: String
14
+ ENCRYPTED_FILE: String
15
+ ENVELOPE_FILE: String
16
+ RSA_OPTIONS: Hash[String, String]
17
+
18
+ # Structural problems: unreadable input, missing zip entries,
19
+ # unsupported envelope algorithms, unloadable keys.
20
+ class CipherError < Capsium::Error
21
+ end
22
+
23
+ # An encrypted package was opened without a private key.
24
+ class KeyRequiredError < CipherError
25
+ end
26
+
27
+ # Decryption failed: wrong private key or tampered ciphertext.
28
+ class DecryptionError < CipherError
29
+ end
30
+
31
+ # Whether the path (.cap file or uncompressed directory) is an
32
+ # encrypted package, i.e. contains package.enc.
33
+ def self.encrypted?: (String | Pathname path) -> bool
34
+
35
+ # Encrypts the package at source_path (a .cap file, or a package
36
+ # directory which is packed first) for the recipient's RSA public
37
+ # key (or X.509 certificate) and writes the encrypted .cap to
38
+ # output_path. Returns output_path.
39
+ def encrypt: (String source_path, String public_key_path, String output_path) -> String
40
+
41
+ # Decrypts the encrypted package at encrypted_path (.cap file or
42
+ # uncompressed directory) with the recipient's RSA private key and
43
+ # writes the plaintext .cap to output_path. Returns output_path.
44
+ def decrypt: (String | Pathname encrypted_path, String private_key_path, String output_path) -> String
45
+
46
+ # Decrypts an encrypted package into a fresh temporary directory
47
+ # and returns the directory path.
48
+ def self.decrypt_to_directory: (String | Pathname source_path, String private_key_path) -> String
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,20 @@
1
+ module Capsium
2
+ class Package
3
+ # Composite-package support (ARCHITECTURE.md section 4a), mixed into
4
+ # Package: metadata.dependencies resolution against a package store
5
+ # and load-time validation of dependency resource references.
6
+ module Composition
7
+ private
8
+
9
+ def resolve_dependencies: ((Store | String)? store, Array[String] chain) -> Array[ResolvedDependency]
10
+
11
+ def validate_dependency_references!: () -> void
12
+
13
+ def validate_dependency_reference: (Route route, ResolvedDependency dependency, String reference) -> void
14
+
15
+ def dependency_for: (String reference) -> ResolvedDependency?
16
+
17
+ def dependency_views: () -> Array[[String, MergedView]]
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,49 @@
1
+ module Capsium
2
+ class Package
3
+ # Base error for composite-package dependency resolution
4
+ # (ARCHITECTURE.md section 4a).
5
+ class DependencyError < Capsium::Error
6
+ end
7
+
8
+ # No package in the store provides the dependency GUID.
9
+ class DependencyNotFoundError < DependencyError
10
+ end
11
+
12
+ # The store provides the GUID but no version satisfies the range.
13
+ class UnsatisfiableDependencyError < DependencyError
14
+ end
15
+
16
+ # A dependency cycle was detected while resolving.
17
+ class CircularDependencyError < DependencyError
18
+ end
19
+
20
+ # A dependent route references a dependency resource that is not
21
+ # exported (private) and therefore not visible to dependents.
22
+ class DependencyVisibilityError < DependencyError
23
+ end
24
+
25
+ # One resolved dependency: the declared GUID and range, the .cap it
26
+ # resolved to and the loaded package.
27
+ class ResolvedDependency
28
+ def self.new: (String guid, String range, String path, Package package) -> void
29
+
30
+ def guid: () -> String
31
+ def range: () -> String
32
+ def path: () -> String
33
+ def package: () -> Package
34
+ def version: () -> String?
35
+ end
36
+
37
+ # Orchestrates metadata.dependencies resolution against a package
38
+ # store: circularity checks plus store lookup.
39
+ class DependencyResolver
40
+ attr_reader store: Store
41
+
42
+ def initialize: (Store | String? store) -> void
43
+
44
+ # The newest store .cap satisfying the dependency, raising typed
45
+ # errors for circular, missing or unsatisfiable dependencies.
46
+ def resolve_path: (String guid, String range, chain: Array[String] chain) -> String
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,35 @@
1
+ module Capsium
2
+ class Package
3
+ # The "encryption" object of signature.json in an encrypted package:
4
+ # the AES-256-GCM envelope with the RSA-OAEP-SHA256 wrapped DEK, all
5
+ # binary values Base64-encoded.
6
+ class EncryptionEnvelope
7
+ attr_accessor algorithm: String?
8
+ attr_accessor key_management: String?
9
+ attr_accessor encrypted_dek: String?
10
+ attr_accessor iv: String?
11
+ attr_accessor auth_tag: String?
12
+
13
+ def initialize: (?algorithm: String? algorithm,
14
+ ?key_management: String? key_management,
15
+ ?encrypted_dek: String? encrypted_dek,
16
+ ?iv: String? iv,
17
+ ?auth_tag: String? auth_tag) -> void
18
+
19
+ def to_json: (*untyped args) -> String
20
+ def to_hash: () -> Hash[String, untyped]
21
+ end
22
+
23
+ # The signature.json file of an encrypted package.
24
+ class EncryptionConfig
25
+ attr_accessor encryption: EncryptionEnvelope?
26
+
27
+ def initialize: (?encryption: EncryptionEnvelope? encryption) -> void
28
+
29
+ def self.from_json: (String json) -> instance
30
+
31
+ def to_json: (*untyped args) -> String
32
+ def to_hash: () -> Hash[String, untyped]
33
+ end
34
+ end
35
+ end