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.
- checksums.yaml +4 -4
- data/.rubocop.yml +7 -3
- data/CHANGELOG.md +197 -0
- data/README.adoc +323 -14
- data/capsium.gemspec +2 -0
- data/lib/capsium/cli/package.rb +62 -25
- data/lib/capsium/cli/reactor.rb +112 -6
- data/lib/capsium/cli.rb +29 -0
- data/lib/capsium/log_buffer.rb +47 -0
- data/lib/capsium/package/bundle.rb +136 -0
- data/lib/capsium/package/cipher.rb +73 -17
- data/lib/capsium/package/composition.rb +28 -12
- data/lib/capsium/package/dataset.rb +20 -0
- data/lib/capsium/package/dependency_resolver.rb +39 -9
- data/lib/capsium/package/encryption_config.rb +7 -3
- data/lib/capsium/package/manifest.rb +1 -1
- data/lib/capsium/package/merged_view.rb +4 -2
- data/lib/capsium/package/metadata.rb +1 -1
- data/lib/capsium/package/metadata_config.rb +9 -1
- data/lib/capsium/package/open_pgp.rb +65 -0
- data/lib/capsium/package/open_pgp_cipher.rb +66 -0
- data/lib/capsium/package/open_pgp_signer.rb +176 -0
- data/lib/capsium/package/security_config.rb +6 -2
- data/lib/capsium/package/signer.rb +18 -3
- data/lib/capsium/package/store.rb +27 -0
- data/lib/capsium/package/validator.rb +27 -2
- data/lib/capsium/package/verification.rb +7 -5
- data/lib/capsium/package.rb +24 -5
- data/lib/capsium/packager.rb +64 -22
- data/lib/capsium/reactor/content_api.rb +110 -0
- data/lib/capsium/reactor/data_api.rb +154 -0
- data/lib/capsium/reactor/endpoints.rb +37 -0
- data/lib/capsium/reactor/graphql_api.rb +123 -0
- data/lib/capsium/reactor/graphql_schema.rb +138 -0
- data/lib/capsium/reactor/introspection.rb +151 -34
- data/lib/capsium/reactor/metrics.rb +30 -0
- data/lib/capsium/reactor/mount.rb +206 -0
- data/lib/capsium/reactor/overlay.rb +248 -0
- data/lib/capsium/reactor/package_saver.rb +99 -0
- data/lib/capsium/reactor/responses.rb +41 -0
- data/lib/capsium/reactor/serving.rb +90 -11
- data/lib/capsium/reactor.rb +96 -49
- data/lib/capsium/registry/local.rb +120 -0
- data/lib/capsium/registry/remote.rb +105 -0
- data/lib/capsium/registry.rb +190 -0
- data/lib/capsium/version.rb +1 -1
- data/lib/capsium.rb +2 -0
- data/sig/capsium/log_buffer.rbs +30 -0
- data/sig/capsium/package/bundle.rbs +43 -0
- data/sig/capsium/package/cipher.rbs +12 -1
- data/sig/capsium/package/composition.rbs +4 -3
- data/sig/capsium/package/dataset.rbs +8 -0
- data/sig/capsium/package/dependency_resolver.rbs +8 -4
- data/sig/capsium/package/encryption_config.rbs +6 -2
- data/sig/capsium/package/merged_view.rbs +2 -1
- data/sig/capsium/package/metadata.rbs +1 -0
- data/sig/capsium/package/open_pgp.rbs +38 -0
- data/sig/capsium/package/open_pgp_cipher.rbs +15 -0
- data/sig/capsium/package/open_pgp_signer.rbs +46 -0
- data/sig/capsium/package/security_config.rbs +6 -3
- data/sig/capsium/package/signer.rbs +8 -1
- data/sig/capsium/package/store.rbs +6 -0
- data/sig/capsium/package/verification.rbs +5 -3
- data/sig/capsium/package.rbs +8 -3
- data/sig/capsium/reactor/content_api.rbs +19 -0
- data/sig/capsium/reactor/data_api.rbs +23 -0
- data/sig/capsium/reactor/endpoints.rbs +16 -0
- data/sig/capsium/reactor/graphql_api.rbs +27 -0
- data/sig/capsium/reactor/graphql_schema.rbs +20 -0
- data/sig/capsium/reactor/introspection.rbs +27 -7
- data/sig/capsium/reactor/metrics.rbs +13 -0
- data/sig/capsium/reactor/mount.rbs +83 -0
- data/sig/capsium/reactor/overlay.rbs +72 -0
- data/sig/capsium/reactor/package_saver.rbs +12 -0
- data/sig/capsium/reactor/responses.rbs +23 -0
- data/sig/capsium/reactor/serving.rbs +28 -4
- data/sig/capsium/reactor.rbs +17 -2
- data/sig/capsium/registry/local.rbs +16 -0
- data/sig/capsium/registry/remote.rbs +18 -0
- data/sig/capsium/registry.rbs +83 -0
- data/sig/graphql.rbs +12 -0
- metadata +66 -1
data/lib/capsium/cli/package.rb
CHANGED
|
@@ -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
|
|
80
|
-
"Sign the package (RSA-SHA256
|
|
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: "
|
|
83
|
-
option :cert, type: :string,
|
|
84
|
-
|
|
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
|
-
|
|
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
|
|
92
|
-
"Verify the
|
|
93
|
-
option :cert, type: :string,
|
|
94
|
-
|
|
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
|
-
|
|
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
|
|
108
|
-
"Encrypt a package (AES-256-GCM
|
|
109
|
-
option :public_key, type: :string,
|
|
110
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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,
|
|
124
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/capsium/cli/reactor.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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&.
|
|
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).
|
|
34
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
152
|
+
gcm_iv = cipher.random_iv
|
|
121
153
|
ciphertext = cipher.update(plaintext) + cipher.final
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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 =
|
|
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
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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)
|