capsium 0.3.0 → 0.6.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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +7 -3
  3. data/CHANGELOG.md +197 -0
  4. data/README.adoc +323 -14
  5. data/capsium.gemspec +2 -0
  6. data/lib/capsium/cli/package.rb +62 -25
  7. data/lib/capsium/cli/reactor.rb +112 -6
  8. data/lib/capsium/cli.rb +29 -0
  9. data/lib/capsium/log_buffer.rb +47 -0
  10. data/lib/capsium/package/bundle.rb +136 -0
  11. data/lib/capsium/package/cipher.rb +73 -17
  12. data/lib/capsium/package/composition.rb +28 -12
  13. data/lib/capsium/package/dataset.rb +20 -0
  14. data/lib/capsium/package/dependency_resolver.rb +39 -9
  15. data/lib/capsium/package/encryption_config.rb +7 -3
  16. data/lib/capsium/package/manifest.rb +1 -1
  17. data/lib/capsium/package/merged_view.rb +4 -2
  18. data/lib/capsium/package/metadata.rb +1 -1
  19. data/lib/capsium/package/metadata_config.rb +9 -1
  20. data/lib/capsium/package/open_pgp.rb +65 -0
  21. data/lib/capsium/package/open_pgp_cipher.rb +66 -0
  22. data/lib/capsium/package/open_pgp_signer.rb +176 -0
  23. data/lib/capsium/package/security_config.rb +6 -2
  24. data/lib/capsium/package/signer.rb +18 -3
  25. data/lib/capsium/package/store.rb +27 -0
  26. data/lib/capsium/package/validator.rb +27 -2
  27. data/lib/capsium/package/verification.rb +7 -5
  28. data/lib/capsium/package.rb +24 -5
  29. data/lib/capsium/packager.rb +64 -22
  30. data/lib/capsium/reactor/content_api.rb +110 -0
  31. data/lib/capsium/reactor/data_api.rb +154 -0
  32. data/lib/capsium/reactor/endpoints.rb +37 -0
  33. data/lib/capsium/reactor/graphql_api.rb +123 -0
  34. data/lib/capsium/reactor/graphql_schema.rb +138 -0
  35. data/lib/capsium/reactor/introspection.rb +151 -34
  36. data/lib/capsium/reactor/metrics.rb +30 -0
  37. data/lib/capsium/reactor/mount.rb +206 -0
  38. data/lib/capsium/reactor/overlay.rb +248 -0
  39. data/lib/capsium/reactor/package_saver.rb +99 -0
  40. data/lib/capsium/reactor/responses.rb +41 -0
  41. data/lib/capsium/reactor/serving.rb +90 -11
  42. data/lib/capsium/reactor.rb +96 -49
  43. data/lib/capsium/registry/local.rb +120 -0
  44. data/lib/capsium/registry/remote.rb +105 -0
  45. data/lib/capsium/registry.rb +190 -0
  46. data/lib/capsium/version.rb +1 -1
  47. data/lib/capsium.rb +2 -0
  48. data/sig/capsium/log_buffer.rbs +30 -0
  49. data/sig/capsium/package/bundle.rbs +43 -0
  50. data/sig/capsium/package/cipher.rbs +12 -1
  51. data/sig/capsium/package/composition.rbs +4 -3
  52. data/sig/capsium/package/dataset.rbs +8 -0
  53. data/sig/capsium/package/dependency_resolver.rbs +8 -4
  54. data/sig/capsium/package/encryption_config.rbs +6 -2
  55. data/sig/capsium/package/merged_view.rbs +2 -1
  56. data/sig/capsium/package/metadata.rbs +1 -0
  57. data/sig/capsium/package/open_pgp.rbs +38 -0
  58. data/sig/capsium/package/open_pgp_cipher.rbs +15 -0
  59. data/sig/capsium/package/open_pgp_signer.rbs +46 -0
  60. data/sig/capsium/package/security_config.rbs +6 -3
  61. data/sig/capsium/package/signer.rbs +8 -1
  62. data/sig/capsium/package/store.rbs +6 -0
  63. data/sig/capsium/package/verification.rbs +5 -3
  64. data/sig/capsium/package.rbs +8 -3
  65. data/sig/capsium/reactor/content_api.rbs +19 -0
  66. data/sig/capsium/reactor/data_api.rbs +23 -0
  67. data/sig/capsium/reactor/endpoints.rbs +16 -0
  68. data/sig/capsium/reactor/graphql_api.rbs +27 -0
  69. data/sig/capsium/reactor/graphql_schema.rbs +20 -0
  70. data/sig/capsium/reactor/introspection.rbs +27 -7
  71. data/sig/capsium/reactor/metrics.rbs +13 -0
  72. data/sig/capsium/reactor/mount.rbs +83 -0
  73. data/sig/capsium/reactor/overlay.rbs +72 -0
  74. data/sig/capsium/reactor/package_saver.rbs +12 -0
  75. data/sig/capsium/reactor/responses.rbs +23 -0
  76. data/sig/capsium/reactor/serving.rbs +28 -4
  77. data/sig/capsium/reactor.rbs +17 -2
  78. data/sig/capsium/registry/local.rbs +16 -0
  79. data/sig/capsium/registry/remote.rbs +18 -0
  80. data/sig/capsium/registry.rbs +83 -0
  81. data/sig/graphql.rbs +12 -0
  82. metadata +66 -1
@@ -50,9 +50,14 @@ module Capsium
50
50
 
51
51
  desc "pack PATH_TO_PACKAGE_FOLDER", "Package the files into the package"
52
52
  option :force, type: :boolean, default: false, aliases: "-f"
53
+ option :bundle_deps, type: :boolean, default: false, aliases: "--bundle",
54
+ desc: "Embed the resolved dependencies under packages/ " \
55
+ "so the .cap activates with no store or registry"
56
+ option :store, type: :string, desc: "Package store directory (default: CAPSIUM_STORE)"
57
+ option :registry, type: :string, desc: "Registry reference (default: CAPSIUM_REGISTRY)"
53
58
 
54
59
  def pack(path_to_package)
55
- package = Capsium::Package.new(path_to_package)
60
+ package = Capsium::Package.new(path_to_package, store: options[:store])
56
61
  packager = Capsium::Packager.new
57
62
  packager.pack(package, options)
58
63
  end
@@ -66,6 +71,18 @@ module Capsium
66
71
  puts "Package unpacked to: #{destination}"
67
72
  end
68
73
 
74
+ desc "push PACKAGE_FILE", "Push a .cap into a static registry directory"
75
+ option :registry, type: :string,
76
+ desc: "Registry directory (default: CAPSIUM_REGISTRY)"
77
+
78
+ def push(path_to_package)
79
+ registry = Capsium::Registry.fetch(options[:registry] || ENV.fetch("CAPSIUM_REGISTRY", nil))
80
+ entry = registry.push(path_to_package)
81
+ puts "Pushed #{entry.name} #{entry.version} to #{registry.location}"
82
+ rescue Capsium::Registry::RegistryError => e
83
+ raise Thor::Error, e.message
84
+ end
85
+
69
86
  desc "validate PACKAGE_PATH", "Validate a package directory or .cap file"
70
87
 
71
88
  def validate(path_to_package)
@@ -76,26 +93,34 @@ module Capsium
76
93
  raise Thor::Error, "Package validation failed"
77
94
  end
78
95
 
79
- desc "sign PACKAGE_PATH --key KEY.pem [--cert CERT.pem]",
80
- "Sign the package (RSA-SHA256) and record the signature in security.json"
96
+ desc "sign PACKAGE_PATH --key KEY [--cert CERT.pem] [--openpgp]",
97
+ "Sign the package (RSA-SHA256/X.509, or OpenPGP) into security.json"
81
98
  option :key, type: :string, required: true,
82
- desc: "Path to the RSA private key PEM (min 2048 bits)"
83
- option :cert, type: :string,
84
- desc: "Path to the X.509 certificate PEM (must match the key)"
99
+ desc: "RSA private key PEM (min 2048 bits); OpenPGP secret key with --openpgp"
100
+ option :cert, type: :string, desc: "X.509 certificate PEM (must match the key)"
101
+ option :openpgp, type: :boolean, default: false, desc: "OpenPGP detached signature"
85
102
 
86
103
  def sign(path_to_package)
87
- Capsium::Package::Signer.sign_package(path_to_package, options[:key], options[:cert])
104
+ if options[:openpgp] && options[:cert]
105
+ raise Thor::Error, "--cert is only used with X.509 signing"
106
+ end
107
+
108
+ if options[:openpgp]
109
+ Capsium::Package::OpenPgpSigner.sign_package(path_to_package, options[:key])
110
+ else
111
+ Capsium::Package::Signer.sign_package(path_to_package, options[:key], options[:cert])
112
+ end
88
113
  puts "Package signed: #{path_to_package}"
89
114
  end
90
115
 
91
- desc "verify-signature PACKAGE_PATH [--cert CERT.pem]",
92
- "Verify the package digital signature declared in security.json"
93
- option :cert, type: :string,
94
- desc: "Path to the X.509 certificate or public key PEM " \
95
- "(defaults to the key embedded in the package)"
116
+ desc "verify-signature PACKAGE_PATH [--cert CERT-or-PUB] [--openpgp]",
117
+ "Verify the declared digital signature (auto-detected from security.json)"
118
+ option :cert, type: :string, desc: "certificate or public key (default: embedded)"
119
+ option :openpgp, type: :boolean, default: false, desc: "verify as OpenPGP"
96
120
 
97
121
  def verify_signature(path_to_package)
98
- unless Capsium::Package::Signer.verify_package(path_to_package, options[:cert])
122
+ verifier = options[:openpgp] ? Capsium::Package::OpenPgpSigner : Capsium::Package::Signer
123
+ unless verifier.verify_package(path_to_package, options[:cert])
99
124
  raise Thor::Error, "Signature verification failed: #{path_to_package}"
100
125
  end
101
126
 
@@ -104,30 +129,42 @@ module Capsium
104
129
  raise Thor::Error, e.message
105
130
  end
106
131
 
107
- desc "encrypt PACKAGE_PATH --public-key PUB.pem -o OUT.cap",
108
- "Encrypt a package (AES-256-GCM, RSA-OAEP-SHA256 wrapped DEK)"
109
- option :public_key, type: :string, required: true,
110
- desc: "Path to the recipient RSA public key or X.509 certificate PEM"
132
+ desc "encrypt PACKAGE_PATH -o OUT.cap [--public-key PUB.pem | --openpgp --recipient PUB]",
133
+ "Encrypt a package (AES-256-GCM; RSA or OpenPGP key management)"
134
+ option :public_key, type: :string, desc: "recipient RSA public key or X.509 certificate PEM"
135
+ option :recipient, type: :string, desc: "recipient OpenPGP public key (with --openpgp)"
136
+ option :openpgp, type: :boolean, default: false, desc: "encrypt for an OpenPGP recipient"
111
137
  option :output, type: :string, required: true, aliases: "-o",
112
138
  desc: "Output path for the encrypted .cap"
113
139
 
114
140
  def encrypt(path_to_package)
115
- Capsium::Package::Cipher.new.encrypt(
116
- path_to_package, options[:public_key], options[:output]
117
- )
141
+ key = options[:public_key] || options[:recipient]
142
+ raise Thor::Error, "encrypt requires --public-key or --openpgp --recipient" if key.nil?
143
+
144
+ cipher = options[:openpgp] ? Capsium::Package::OpenPgpCipher.new : Capsium::Package::Cipher.new
145
+ cipher.encrypt(path_to_package, key, options[:output])
118
146
  puts "Package encrypted: #{options[:output]}"
119
147
  end
120
148
 
121
- desc "decrypt PACKAGE_PATH --private-key PRIV.pem [-o OUT.cap]",
122
- "Decrypt an encrypted package"
123
- option :private_key, type: :string, required: true,
124
- desc: "Path to the recipient RSA private key PEM"
149
+ desc "decrypt PACKAGE_PATH [--private-key PRIV.pem | --openpgp --key SEC.asc] [-o OUT.cap]",
150
+ "Decrypt an encrypted package (key management auto-detected from the envelope)"
151
+ option :private_key, type: :string, desc: "recipient RSA private key PEM"
152
+ option :key, type: :string, desc: "recipient OpenPGP secret key"
153
+ option :openpgp, type: :boolean, default: false, desc: "decrypt with an OpenPGP key"
125
154
  option :output, type: :string, aliases: "-o",
126
155
  desc: "Output path (default: <name>-decrypted.cap)"
127
156
 
128
157
  def decrypt(path_to_package)
158
+ key = options[:private_key] || options[:key]
159
+ raise Thor::Error, "decrypt requires --private-key (RSA) or --key (OpenPGP)" if key.nil?
160
+
129
161
  output = options[:output] || "#{File.basename(path_to_package, '.cap')}-decrypted.cap"
130
- Capsium::Package::Cipher.new.decrypt(path_to_package, options[:private_key], output)
162
+ cipher = if options[:openpgp]
163
+ Capsium::Package::OpenPgpCipher.new
164
+ else
165
+ Capsium::Package::Cipher.for_encrypted(path_to_package)
166
+ end
167
+ cipher.decrypt(path_to_package, key, output)
131
168
  puts "Package decrypted: #{output}"
132
169
  end
133
170
 
@@ -5,8 +5,9 @@ module Capsium
5
5
  class Reactor < Thor
6
6
  extend ThorExt::Start
7
7
 
8
- desc "serve PACKAGE_PATH",
9
- "Start the Capsium reactor to serve the package"
8
+ desc "serve PACKAGE_PATH ...",
9
+ "Start the Capsium reactor to serve one or more packages " \
10
+ "(local packages, or capsium:// GUIDs installed from a registry)"
10
11
  option :port, type: :numeric, default: Capsium::Reactor::DEFAULT_PORT
11
12
  option :do_not_listen, type: :boolean, default: false
12
13
  option :store, type: :string,
@@ -15,18 +16,123 @@ module Capsium
15
16
  option :deploy, type: :string,
16
17
  desc: "deploy.json with reactor-side secrets " \
17
18
  "(default: CAPSIUM_DEPLOY)"
19
+ option :registry, type: :string,
20
+ desc: "Registry directory or https base URL for " \
21
+ "capsium:// GUIDs and dependency fallback " \
22
+ "(default: CAPSIUM_REGISTRY)"
23
+ option :constraint, type: :string, default: "*",
24
+ desc: "Semver constraint for a capsium:// GUID"
25
+ option :mount, type: :array,
26
+ desc: "Mount a source at a URL prefix, PATH=SOURCE " \
27
+ "(repeatable; sources without a prefix default " \
28
+ "to / for the first and /<name>/ for the rest)"
29
+ option :config, type: :string,
30
+ desc: "JSON mount config: " \
31
+ '{"mounts": [{"path": "/", "source": "...", "store": "..."}]}'
32
+ option :workdir, type: :string,
33
+ desc: "Reactor work directory for writable overlays " \
34
+ "and saved packages (default: a temporary directory)"
18
35
 
19
- def serve(path_to_package)
36
+ # Thor array options are last-wins when the flag repeats; collect
37
+ # every --mount value (both "--mount V" and "--mount=V" forms)
38
+ # and re-emit one trailing occurrence so repetition accumulates.
39
+ # (Subcommands are dispatched in-process, so this hooks dispatch,
40
+ # not start; merging is idempotent for the direct-start flow.)
41
+ def self.dispatch(meth, given_args, given_opts, config)
42
+ super(meth, merge_mount_options(given_args),
43
+ given_opts && merge_mount_options(given_opts), config)
44
+ end
45
+
46
+ def self.merge_mount_options(args)
47
+ values = []
48
+ rest = []
49
+ index = 0
50
+ while index < args.length
51
+ arg = args[index]
52
+ if arg == "--mount"
53
+ index += 1
54
+ while index < args.length && !args[index].start_with?("-")
55
+ values << args[index]
56
+ index += 1
57
+ end
58
+ elsif arg.start_with?("--mount=")
59
+ values << arg.split("=", 2).last
60
+ index += 1
61
+ else
62
+ rest << arg
63
+ index += 1
64
+ end
65
+ end
66
+ values.empty? ? args : rest + ["--mount"] + values
67
+ end
68
+
69
+ def serve(*sources)
70
+ entries = mount_entries(sources)
71
+ if entries.empty?
72
+ raise Thor::Error, "no package source given (positional " \
73
+ "arguments, --mount or --config)"
74
+ end
75
+
76
+ mounts = Capsium::Reactor::Mount.build(
77
+ entries, store: options[:store], registry: options[:registry]
78
+ )
20
79
  reactor = Capsium::Reactor.new(
21
- package: path_to_package,
80
+ mounts: mounts,
22
81
  port: options[:port],
23
82
  do_not_listen: options[:do_not_listen],
24
83
  store: options[:store],
25
- deploy: options[:deploy]
84
+ deploy: options[:deploy],
85
+ registry: options[:registry],
86
+ workdir: options[:workdir]
26
87
  )
27
88
  reactor.serve
89
+ rescue Capsium::Error => e
90
+ raise Thor::Error, e.message
28
91
  ensure
29
- reactor&.package&.cleanup
92
+ reactor&.cleanup
93
+ end
94
+
95
+ private
96
+
97
+ # The combined mount entries from --config, --mount and the
98
+ # positional sources (in that order), with capsium:// GUIDs
99
+ # installed from the registry into the store.
100
+ def mount_entries(sources)
101
+ entries = []
102
+ entries.concat(Capsium::Reactor::Mount.config_entries(options[:config])) if options[:config]
103
+ entries.concat(Array(options[:mount]).map do |spec|
104
+ Capsium::Reactor::Mount.parse_spec(spec)
105
+ end)
106
+ entries.concat(sources.map do |source|
107
+ Capsium::Reactor::Mount::Entry.new(path: nil, source: source, store: nil)
108
+ end)
109
+ entries.map do |entry|
110
+ entry.with(source: resolve_source(entry.source))
111
+ end
112
+ end
113
+
114
+ def resolve_source(source)
115
+ return source unless source.start_with?("capsium://")
116
+
117
+ install_from_registry(source)
118
+ end
119
+
120
+ # Install-then-serve for capsium:// GUIDs: resolve and install
121
+ # from the registry into the package store, returning the
122
+ # installed store .cap path.
123
+ def install_from_registry(guid)
124
+ registry = Capsium::Registry.fetch(options[:registry] || ENV.fetch("CAPSIUM_REGISTRY", nil))
125
+ store = options[:store] || ENV.fetch("CAPSIUM_STORE", nil)
126
+ if store.nil? || store.empty?
127
+ raise Thor::Error,
128
+ "no package store configured (pass --store or set CAPSIUM_STORE)"
129
+ end
130
+
131
+ path = registry.install(guid, options[:constraint], store: store)
132
+ puts "Installed #{guid} to #{path}"
133
+ path
134
+ rescue Capsium::Registry::RegistryError => e
135
+ raise Thor::Error, e.message
30
136
  end
31
137
  end
32
138
  end
data/lib/capsium/cli.rb CHANGED
@@ -19,5 +19,34 @@ module Capsium
19
19
 
20
20
  desc "convert SUBCOMMAND ...ARGS", "Convert from another format"
21
21
  subcommand "convert", Capsium::Cli::Convert
22
+
23
+ desc "install GUID", "Install a package from a registry into the package store"
24
+ option :constraint, type: :string, default: "*",
25
+ desc: "Semver constraint the installed version must satisfy"
26
+ option :registry, type: :string,
27
+ desc: "Registry directory or https base URL " \
28
+ "(default: CAPSIUM_REGISTRY)"
29
+ option :store, type: :string,
30
+ desc: "Package store directory (default: CAPSIUM_STORE)"
31
+
32
+ def install(guid)
33
+ registry = Capsium::Registry.fetch(options[:registry] || ENV.fetch("CAPSIUM_REGISTRY", nil))
34
+ path = registry.install(guid, options[:constraint], store: store_dir!)
35
+ puts "Installed #{guid} to #{path}"
36
+ rescue Capsium::Registry::RegistryError => e
37
+ raise Thor::Error, e.message
38
+ end
39
+
40
+ private
41
+
42
+ # The store directory for install-like commands: --store or
43
+ # CAPSIUM_STORE, otherwise a typed CLI error.
44
+ def store_dir!
45
+ store = options[:store] || ENV.fetch("CAPSIUM_STORE", nil)
46
+ return store unless store.nil? || store.empty?
47
+
48
+ raise Thor::Error,
49
+ "no package store configured (pass --store or set CAPSIUM_STORE)"
50
+ end
22
51
  end
23
52
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Capsium
6
+ # A small thread-safe ring buffer of timestamped log entries with a
7
+ # fixed capacity: when full, the oldest entry is dropped. The reactor
8
+ # records key serving events here and exposes the most recent lines
9
+ # through the /package/:id/logs introspection endpoint.
10
+ class LogBuffer
11
+ DEFAULT_CAPACITY = 500
12
+
13
+ # One buffer entry: a UTC timestamp and a message.
14
+ Entry = Data.define(:timestamp, :message) do
15
+ # "2026-07-19T15:00:00Z message"
16
+ def line = "#{timestamp.utc.iso8601} #{message}"
17
+ end
18
+
19
+ attr_reader :capacity
20
+
21
+ def initialize(capacity: DEFAULT_CAPACITY)
22
+ raise ArgumentError, "capacity must be at least 1" if capacity < 1
23
+
24
+ @capacity = capacity
25
+ @entries = []
26
+ @mutex = Mutex.new
27
+ end
28
+
29
+ def add(message, timestamp: Time.now)
30
+ @mutex.synchronize do
31
+ @entries.shift if @entries.size >= @capacity
32
+ @entries << Entry.new(timestamp: timestamp, message: message)
33
+ end
34
+ self
35
+ end
36
+
37
+ # The last count entries, oldest first.
38
+ def last(count)
39
+ @mutex.synchronize { @entries.last(count) }
40
+ end
41
+
42
+ # The last count entries as formatted lines, oldest first.
43
+ def lines(count)
44
+ last(count).map(&:line)
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "zip"
7
+
8
+ module Capsium
9
+ class Package
10
+ # Bundled dependencies of an encapsulated (self-contained) package
11
+ # (ARCHITECTURE.md section 4a extension): dependency .cap files
12
+ # embedded under packages/ with a packages/index.json manifest
13
+ # mapping each dependency GUID to its file, version and SHA-256:
14
+ #
15
+ # packages/
16
+ # index.json # guid => {"file", "version", "sha256"}
17
+ # <name>-<version>.cap # one per declared dependency
18
+ #
19
+ # One-level bundling policy: only the dependencies DECLARED in the
20
+ # package's own metadata.dependencies are bundled; to make a whole
21
+ # tree self-contained the parent must declare the transitive
22
+ # closure. At load time bundled dependencies resolve FIRST, before
23
+ # the store -> registry chain, and a package's bundle is passed down
24
+ # to its dependencies so their re-declared dependencies resolve from
25
+ # it too (recursive-capable).
26
+ #
27
+ # Security: bundled .cap files are covered by the parent package's
28
+ # security.json checksums like every other file; the manifest
29
+ # SHA-256 is re-verified when a bundled dependency is resolved, and
30
+ # each bundled package's own security.json is verified when it is
31
+ # loaded (the usual Capsium::Package activation checks).
32
+ class Bundle
33
+ DIRECTORY = "packages"
34
+ INDEX_FILE = "packages/index.json"
35
+
36
+ # One bundled dependency: the absolute .cap path, its version and
37
+ # the SHA-256 recorded in the manifest.
38
+ Entry = Data.define(:file, :version, :sha256)
39
+
40
+ attr_reader :entries
41
+
42
+ def initialize(package_path, entries = nil)
43
+ @entries = entries || load_entries(package_path)
44
+ end
45
+
46
+ # Embeds the resolved dependency .cap files (guid => .cap path
47
+ # pairs, in declaration order) into the package directory at
48
+ # package_path: copies each under packages/ and writes the
49
+ # packages/index.json manifest. Returns the manifest hash.
50
+ def self.write(package_path, resolutions)
51
+ FileUtils.mkdir_p(File.join(package_path, DIRECTORY))
52
+ index = resolutions.to_h do |guid, cap_path|
53
+ file = File.join(DIRECTORY, File.basename(cap_path))
54
+ FileUtils.cp(cap_path, File.join(package_path, file))
55
+ [guid, { "file" => file,
56
+ "version" => cap_version(cap_path),
57
+ "sha256" => Digest::SHA256.file(cap_path).hexdigest }]
58
+ end
59
+ File.write(File.join(package_path, INDEX_FILE), JSON.pretty_generate(index))
60
+ index
61
+ end
62
+
63
+ def self.cap_version(cap_path)
64
+ Zip::File.open(cap_path) do |zip|
65
+ entry = zip.find_entry(METADATA_FILE)
66
+ raise DependencyError, "no #{METADATA_FILE} in #{cap_path}" unless entry
67
+
68
+ JSON.parse(entry.get_input_stream.read)["version"].to_s
69
+ end
70
+ end
71
+ private_class_method :cap_version
72
+
73
+ # Whether this bundle embeds any dependency.
74
+ def present? = !@entries.empty?
75
+
76
+ # Merges an inherited (ancestor) bundle underneath this one; own
77
+ # entries win on GUID conflicts. Entries carry absolute paths, so
78
+ # merged entries keep pointing into their source packages.
79
+ def merged_with(other)
80
+ return self if other.nil? || !other.present?
81
+ return other unless present?
82
+
83
+ self.class.new(nil, other.entries.merge(@entries))
84
+ end
85
+
86
+ # The absolute .cap path for a bundled dependency, or nil when the
87
+ # GUID is not bundled (the caller falls back to the store ->
88
+ # registry chain). Raises CircularDependencyError when the GUID is
89
+ # already being resolved up-chain, UnsatisfiableDependencyError
90
+ # when the bundled version does not satisfy the declared range and
91
+ # Security::IntegrityError when the bundled file fails the
92
+ # manifest SHA-256.
93
+ def resolve_path(guid, range, chain:)
94
+ entry = @entries[guid]
95
+ return nil if entry.nil?
96
+
97
+ if chain.include?(guid)
98
+ raise CircularDependencyError,
99
+ "circular dependency: #{(chain + [guid]).join(' -> ')}"
100
+ end
101
+
102
+ unless VersionRange.parse(range).satisfied_by?(Version.parse(entry.version))
103
+ raise UnsatisfiableDependencyError,
104
+ "bundled #{guid} #{entry.version} does not satisfy '#{range}'"
105
+ end
106
+
107
+ verify_sha256(guid, entry)
108
+ entry.file
109
+ end
110
+
111
+ private
112
+
113
+ def verify_sha256(guid, entry)
114
+ actual = Digest::SHA256.file(entry.file).hexdigest
115
+ return if actual == entry.sha256
116
+
117
+ raise Security::IntegrityError,
118
+ "bundled package failed manifest SHA-256 verification: " \
119
+ "#{guid} (#{entry.file})"
120
+ end
121
+
122
+ def load_entries(package_path)
123
+ index_path = File.join(package_path, INDEX_FILE)
124
+ return {} unless File.file?(index_path)
125
+
126
+ JSON.parse(File.read(index_path)).to_h do |guid, entry|
127
+ [guid, Entry.new(file: File.join(package_path, entry.fetch("file")),
128
+ version: entry.fetch("version"),
129
+ sha256: entry.fetch("sha256"))]
130
+ end
131
+ rescue JSON::ParserError, KeyError => e
132
+ raise DependencyError, "invalid #{INDEX_FILE}: #{e.message}"
133
+ end
134
+ end
135
+ end
136
+ end
@@ -30,8 +30,10 @@ module Capsium
30
30
  #
31
31
  # A random 256-bit data encryption key (DEK) encrypts the inner zip;
32
32
  # the DEK is wrapped with the recipient's RSA public key using OAEP
33
- # with SHA-256 (MGF1-SHA-256). The OCB/OpenPGP alternatives mentioned
34
- # by the standard are intentionally out of scope.
33
+ # with SHA-256 (MGF1-SHA-256). OpenPGP key management (an armored
34
+ # OpenPGP message carrying the DEK) is provided by the parallel
35
+ # OpenPgpCipher subclass; the OCB alternative mentioned by the
36
+ # standard remains out of scope.
35
37
  class Cipher
36
38
  ALGORITHM = "AES-256-GCM"
37
39
  KEY_MANAGEMENT = "RSA-OAEP-SHA256"
@@ -61,6 +63,33 @@ module Capsium
61
63
  false
62
64
  end
63
65
 
66
+ # The keyManagement declared by the encryption envelope of the
67
+ # path (.cap file or uncompressed directory), or nil when the
68
+ # envelope is absent or unreadable.
69
+ def self.key_management(path)
70
+ source = envelope_source(path)
71
+ source && EncryptionConfig.from_json(source).encryption&.key_management
72
+ rescue Lutaml::Model::Error, JSON::ParserError
73
+ nil
74
+ end
75
+
76
+ # The cipher instance matching the envelope's keyManagement:
77
+ # OpenPgpCipher for OpenPGP envelopes, the RSA cipher otherwise.
78
+ def self.for_encrypted(path)
79
+ key_management(path) == OpenPgpCipher::KEY_MANAGEMENT ? OpenPgpCipher.new : new
80
+ end
81
+
82
+ def self.envelope_source(path)
83
+ envelope = File.join(path, ENVELOPE_FILE)
84
+ return File.read(envelope) if File.directory?(path) && File.file?(envelope)
85
+ return nil if File.directory?(path)
86
+
87
+ Zip::File.open(path) { |zip| zip.find_entry(ENVELOPE_FILE)&.get_input_stream&.read }
88
+ rescue Errno::ENOENT, Zip::Error
89
+ nil
90
+ end
91
+ private_class_method :envelope_source
92
+
64
93
  # Encrypts the package at source_path (a .cap file, or a package
65
94
  # directory which is packed first) for the recipient's RSA public
66
95
  # key (or X.509 certificate) and writes the encrypted .cap to
@@ -87,11 +116,14 @@ module Capsium
87
116
  end
88
117
 
89
118
  # Decrypts an encrypted package into a fresh temporary directory
90
- # and returns the directory path.
119
+ # and returns the directory path. The cipher is selected from the
120
+ # envelope's keyManagement (for_encrypted), so
121
+ # Package.new(decryption_key:) accepts either key format
122
+ # transparently.
91
123
  def self.decrypt_to_directory(source_path, private_key_path)
92
124
  Dir.mktmpdir.tap do |tmp|
93
125
  inner_cap = File.join(tmp, "inner.cap")
94
- new.decrypt(source_path, private_key_path, inner_cap)
126
+ for_encrypted(source_path).decrypt(source_path, private_key_path, inner_cap)
95
127
  package_path = File.join(tmp, File.basename(source_path.to_s, ".cap"))
96
128
  FileUtils.mkdir_p(package_path)
97
129
  Packager.new.unpack(inner_cap, package_path)
@@ -117,29 +149,51 @@ module Capsium
117
149
  cipher = OpenSSL::Cipher.new(ALGORITHM.downcase)
118
150
  cipher.encrypt
119
151
  dek = cipher.random_key
120
- iv = cipher.random_iv
152
+ gcm_iv = cipher.random_iv
121
153
  ciphertext = cipher.update(plaintext) + cipher.final
122
- envelope = EncryptionEnvelope.new(
123
- algorithm: ALGORITHM, key_management: KEY_MANAGEMENT,
124
- encrypted_dek: Base64.strict_encode64(public_key.encrypt(dek, RSA_OPTIONS)),
125
- iv: Base64.strict_encode64(iv), auth_tag: Base64.strict_encode64(cipher.auth_tag)
154
+ [envelope_for(dek, gcm_iv, cipher.auth_tag, public_key), ciphertext]
155
+ end
156
+
157
+ # The encryption envelope carrying the DEK protected for the
158
+ # recipient (RSA-OAEP-SHA256 wrapped here; OpenPgpCipher overrides
159
+ # this with an OpenPGP message). The subclass seam for key
160
+ # management; ALGORITHM/iv/authTag stay as-is.
161
+ def envelope_for(dek, gcm_iv, auth_tag, public_key)
162
+ EncryptionConfig.new(
163
+ encryption: EncryptionEnvelope.new(
164
+ algorithm: ALGORITHM, key_management: KEY_MANAGEMENT,
165
+ encrypted_dek: Base64.strict_encode64(public_key.encrypt(dek, RSA_OPTIONS)),
166
+ iv: Base64.strict_encode64(gcm_iv), auth_tag: Base64.strict_encode64(auth_tag)
167
+ )
126
168
  )
127
- [EncryptionConfig.new(encryption: envelope), ciphertext]
128
169
  end
129
170
 
130
171
  def decrypt_bytes(ciphertext, envelope, private_key)
131
- dek = private_key.decrypt(Base64.strict_decode64(envelope.encrypted_dek), RSA_OPTIONS)
172
+ dek = unwrap_dek(envelope, private_key)
173
+ gcm_decrypt(ciphertext, dek, envelope)
174
+ rescue ArgumentError
175
+ raise CipherError, "invalid Base64 in the encryption envelope"
176
+ end
177
+
178
+ # Recovers the DEK from the envelope (RSA-OAEP-SHA256 unwrap here;
179
+ # OpenPgpCipher overrides this with an OpenPGP decryption).
180
+ def unwrap_dek(envelope, private_key)
181
+ private_key.decrypt(Base64.strict_decode64(envelope.encrypted_dek), RSA_OPTIONS)
182
+ rescue OpenSSL::PKey::PKeyError
183
+ # DEK unwrap failure (wrong key or a malformed envelope).
184
+ raise DecryptionError, "decryption failed: wrong key or tampered package"
185
+ end
186
+
187
+ def gcm_decrypt(ciphertext, dek, envelope)
132
188
  cipher = OpenSSL::Cipher.new(ALGORITHM.downcase)
133
189
  cipher.decrypt
134
190
  cipher.key = dek
135
191
  cipher.iv = Base64.strict_decode64(envelope.iv)
136
192
  cipher.auth_tag = Base64.strict_decode64(envelope.auth_tag)
137
193
  cipher.update(ciphertext) + cipher.final
138
- rescue ArgumentError
139
- raise CipherError, "invalid Base64 in the encryption envelope"
140
- rescue OpenSSL::PKey::PKeyError, OpenSSL::Cipher::CipherError
141
- # DEK unwrap failure or GCM tag verification failure (wrong key
142
- # or tampered ciphertext), or a malformed envelope iv/authTag.
194
+ rescue OpenSSL::Cipher::CipherError
195
+ # GCM tag verification failure (wrong key or tampered
196
+ # ciphertext), or a malformed envelope iv/authTag.
143
197
  raise DecryptionError, "decryption failed: wrong key or tampered package"
144
198
  end
145
199
 
@@ -162,7 +216,9 @@ module Capsium
162
216
  end
163
217
 
164
218
  def supported_envelope?(envelope)
165
- [envelope.algorithm, envelope.key_management] == [ALGORITHM, KEY_MANAGEMENT]
219
+ # self.class:: so subclasses (OpenPgpCipher) match their own
220
+ # KEY_MANAGEMENT despite Ruby's lexical constant lookup.
221
+ [envelope.algorithm, envelope.key_management] == [ALGORITHM, self.class::KEY_MANAGEMENT]
166
222
  end
167
223
 
168
224
  def read_source(path, entry_name)