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
|
@@ -3,28 +3,44 @@
|
|
|
3
3
|
module Capsium
|
|
4
4
|
class Package
|
|
5
5
|
# Composite-package support (ARCHITECTURE.md section 4a), mixed into
|
|
6
|
-
# Package: metadata.dependencies resolution
|
|
7
|
-
#
|
|
6
|
+
# Package: metadata.dependencies resolution — bundled dependencies
|
|
7
|
+
# (Capsium::Package::Bundle) first, then the package store — and
|
|
8
|
+
# load-time validation of dependency resource references.
|
|
8
9
|
module Composition
|
|
9
10
|
private
|
|
10
11
|
|
|
11
|
-
# Resolves metadata.dependencies
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
|
|
12
|
+
# Resolves metadata.dependencies: bundled dependencies first
|
|
13
|
+
# (Capsium::Package::Bundle — encapsulated packages embed their
|
|
14
|
+
# dependencies under packages/, so they activate with no store or
|
|
15
|
+
# registry), then the store (the `store` argument, else
|
|
16
|
+
# CAPSIUM_STORE) with the given `registry` (else CAPSIUM_REGISTRY)
|
|
17
|
+
# as install fallback for store misses. The store resolver is only
|
|
18
|
+
# constructed when a dependency is not bundled, so a fully
|
|
19
|
+
# bundled package needs neither. Each resolved dependency loads
|
|
20
|
+
# recursively with an extended circularity chain and this
|
|
21
|
+
# package's bundle underneath its own (re-declared transitive
|
|
22
|
+
# dependencies resolve from it); its exported content becomes
|
|
23
|
+
# lower read-only layers of this package's merged view.
|
|
24
|
+
def resolve_dependencies(store, registry, chain, bundle = nil)
|
|
17
25
|
declared = @metadata.dependencies
|
|
18
26
|
return [] if declared.empty?
|
|
19
27
|
|
|
20
|
-
resolver = DependencyResolver.new(store || Store.default)
|
|
21
28
|
chain += [@metadata.guid]
|
|
29
|
+
bundle = Bundle.new(@path).merged_with(bundle)
|
|
30
|
+
resolver = nil
|
|
22
31
|
declared.map do |guid, range|
|
|
23
|
-
cap_path =
|
|
32
|
+
cap_path = bundle.resolve_path(guid, range, chain: chain)
|
|
33
|
+
if cap_path.nil?
|
|
34
|
+
resolver ||= DependencyResolver.new(store || Store.default, registry: registry)
|
|
35
|
+
cap_path = resolver.resolve_path(guid, range, chain: chain)
|
|
36
|
+
end
|
|
24
37
|
ResolvedDependency.new(
|
|
25
38
|
guid: guid, range: range, path: cap_path,
|
|
26
|
-
package: Package.new(cap_path,
|
|
27
|
-
|
|
39
|
+
package: Package.new(cap_path,
|
|
40
|
+
store: resolver ? resolver.store : store,
|
|
41
|
+
registry: resolver ? resolver.registry : registry,
|
|
42
|
+
dependency_chain: chain,
|
|
43
|
+
bundle: bundle)
|
|
28
44
|
)
|
|
29
45
|
end
|
|
30
46
|
end
|
|
@@ -67,6 +67,26 @@ module Capsium
|
|
|
67
67
|
problems
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
+
# The parsed JSON schema for this dataset, or nil when none is
|
|
71
|
+
# declared or the file is unreadable.
|
|
72
|
+
def json_schema
|
|
73
|
+
return nil unless @config.schema_file && @config.schema_type == "json-schema"
|
|
74
|
+
return nil unless File.file?(schema_path)
|
|
75
|
+
|
|
76
|
+
load_schema
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Schema validation of an arbitrary candidate document (used by
|
|
80
|
+
# the reactor's writable overlay before persisting a mutation):
|
|
81
|
+
# human-readable schema problems, empty when valid or when the
|
|
82
|
+
# dataset has no JSON schema.
|
|
83
|
+
def schema_errors_for(data)
|
|
84
|
+
return [] unless @config.schema_file && @config.schema_type == "json-schema"
|
|
85
|
+
return [] unless File.file?(schema_path)
|
|
86
|
+
|
|
87
|
+
JSON::Validator.fully_validate(load_schema, JSON.parse(JSON.generate(data)))
|
|
88
|
+
end
|
|
89
|
+
|
|
70
90
|
private
|
|
71
91
|
|
|
72
92
|
def schema_validation_errors
|
|
@@ -28,12 +28,14 @@ module Capsium
|
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
# Orchestrates metadata.dependencies resolution against a package
|
|
31
|
-
# store: circularity checks plus store lookup
|
|
32
|
-
#
|
|
31
|
+
# store: circularity checks plus store lookup, with a registry
|
|
32
|
+
# (Capsium::Registry) as install fallback for store misses.
|
|
33
|
+
# Package loads the resolved .cap itself (recursively, with an
|
|
34
|
+
# extended chain).
|
|
33
35
|
class DependencyResolver
|
|
34
|
-
attr_reader :store
|
|
36
|
+
attr_reader :store, :registry
|
|
35
37
|
|
|
36
|
-
def initialize(store)
|
|
38
|
+
def initialize(store, registry: nil)
|
|
37
39
|
if store.nil?
|
|
38
40
|
raise DependencyError,
|
|
39
41
|
"metadata.dependencies declared but no package store " \
|
|
@@ -41,13 +43,21 @@ module Capsium
|
|
|
41
43
|
end
|
|
42
44
|
|
|
43
45
|
@store = store.is_a?(Store) ? store : Store.new(store.to_s)
|
|
46
|
+
@registry = if registry.nil? || registry.is_a?(Capsium::Registry)
|
|
47
|
+
registry
|
|
48
|
+
else
|
|
49
|
+
Capsium::Registry.fetch(registry)
|
|
50
|
+
end
|
|
51
|
+
@registry ||= Capsium::Registry.default
|
|
44
52
|
end
|
|
45
53
|
|
|
46
|
-
# The newest store .cap satisfying the dependency.
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
54
|
+
# The newest store .cap satisfying the dependency. Falls back to
|
|
55
|
+
# installing from the configured registry when the store has no
|
|
56
|
+
# package for the GUID. Raises CircularDependencyError when the
|
|
57
|
+
# GUID is already being resolved up-chain,
|
|
58
|
+
# DependencyNotFoundError when neither the store nor a registry
|
|
59
|
+
# provides it and UnsatisfiableDependencyError when no available
|
|
60
|
+
# version satisfies the range.
|
|
51
61
|
def resolve_path(guid, range, chain:)
|
|
52
62
|
if chain.include?(guid)
|
|
53
63
|
raise CircularDependencyError,
|
|
@@ -55,6 +65,26 @@ module Capsium
|
|
|
55
65
|
end
|
|
56
66
|
|
|
57
67
|
@store.find(guid, range)
|
|
68
|
+
rescue DependencyNotFoundError => e
|
|
69
|
+
resolve_from_registry(guid, range, e)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# Registry fallback for store misses (fallback chain: store ->
|
|
75
|
+
# registry -> typed error). Installs the newest satisfying version
|
|
76
|
+
# into the store and returns its path; re-raises the store error
|
|
77
|
+
# when no registry is configured.
|
|
78
|
+
def resolve_from_registry(guid, range, store_error)
|
|
79
|
+
raise store_error if @registry.nil?
|
|
80
|
+
|
|
81
|
+
@registry.install(guid, range, store: @store)
|
|
82
|
+
rescue Registry::PackageNotFoundError
|
|
83
|
+
raise DependencyNotFoundError,
|
|
84
|
+
"#{store_error.message}; no package #{guid} in registry " \
|
|
85
|
+
"#{@registry.location} either"
|
|
86
|
+
rescue Registry::UnsatisfiableConstraintError => e
|
|
87
|
+
raise UnsatisfiableDependencyError, e.message
|
|
58
88
|
end
|
|
59
89
|
end
|
|
60
90
|
end
|
|
@@ -5,13 +5,16 @@ require "lutaml/model"
|
|
|
5
5
|
module Capsium
|
|
6
6
|
class Package
|
|
7
7
|
# The "encryption" object of signature.json in an encrypted package
|
|
8
|
-
# (05x-packaging "Encryption"): the AES-256-GCM envelope
|
|
9
|
-
#
|
|
10
|
-
#
|
|
8
|
+
# (05x-packaging "Encryption"): the AES-256-GCM envelope, all binary
|
|
9
|
+
# values Base64-encoded. keyManagement selects how the data
|
|
10
|
+
# encryption key (DEK) is protected: "RSA-OAEP-SHA256" carries it in
|
|
11
|
+
# encryptedDek (RSA-wrapped); "OpenPGP" carries an armored OpenPGP
|
|
12
|
+
# message containing the DEK in message.
|
|
11
13
|
class EncryptionEnvelope < Lutaml::Model::Serializable
|
|
12
14
|
attribute :algorithm, :string
|
|
13
15
|
attribute :key_management, :string
|
|
14
16
|
attribute :encrypted_dek, :string
|
|
17
|
+
attribute :message, :string
|
|
15
18
|
attribute :iv, :string
|
|
16
19
|
attribute :auth_tag, :string
|
|
17
20
|
|
|
@@ -19,6 +22,7 @@ module Capsium
|
|
|
19
22
|
map :algorithm, to: :algorithm
|
|
20
23
|
map "keyManagement", to: :key_management
|
|
21
24
|
map "encryptedDek", to: :encrypted_dek
|
|
25
|
+
map :message, to: :message
|
|
22
26
|
map :iv, to: :iv
|
|
23
27
|
map "authTag", to: :auth_tag
|
|
24
28
|
end
|
|
@@ -26,6 +26,8 @@ module Capsium
|
|
|
26
26
|
# With `exported_only: true` (the view a dependent package gets):
|
|
27
27
|
# layers whose visibility is "private" are hidden entirely, and a path
|
|
28
28
|
# resolves only when the manifest lists the resource as "exported".
|
|
29
|
+
# `extra_layers` stack on top of ALL own layers (e.g. the reactor's
|
|
30
|
+
# writable overlay, section 5a).
|
|
29
31
|
class MergedView
|
|
30
32
|
# A single read layer: an absolute root directory mirroring the
|
|
31
33
|
# content/ tree, plus its parsed tombstone set.
|
|
@@ -45,13 +47,13 @@ module Capsium
|
|
|
45
47
|
attr_reader :package_path, :layers, :dependency_views
|
|
46
48
|
|
|
47
49
|
def initialize(package_path, storage:, manifest:, dependency_views: [],
|
|
48
|
-
exported_only: false)
|
|
50
|
+
exported_only: false, extra_layers: [])
|
|
49
51
|
@package_path = package_path
|
|
50
52
|
@storage = storage
|
|
51
53
|
@manifest = manifest
|
|
52
54
|
@exported_only = exported_only
|
|
53
55
|
@dependency_views = dependency_views
|
|
54
|
-
@layers = build_layers
|
|
56
|
+
@layers = build_layers + extra_layers
|
|
55
57
|
end
|
|
56
58
|
|
|
57
59
|
# The absolute filesystem path serving a resource reference: a
|
|
@@ -36,6 +36,7 @@ module Capsium
|
|
|
36
36
|
attribute :repository, Repository
|
|
37
37
|
attribute :dependencies, :hash, default: {}
|
|
38
38
|
attribute :read_only, :boolean
|
|
39
|
+
attribute :modules, :string, collection: true
|
|
39
40
|
|
|
40
41
|
json do
|
|
41
42
|
map :name, to: :name
|
|
@@ -48,6 +49,7 @@ module Capsium
|
|
|
48
49
|
map :repository, to: :repository
|
|
49
50
|
map :dependencies, to: :dependencies
|
|
50
51
|
map "readOnly", to: :read_only
|
|
52
|
+
map :modules, to: :modules
|
|
51
53
|
end
|
|
52
54
|
|
|
53
55
|
def self.from_json(json)
|
|
@@ -88,7 +90,13 @@ module Capsium
|
|
|
88
90
|
problems << "version must be semver" if invalid?(version, SEMVER_PATTERN)
|
|
89
91
|
problems << "guid must be a URI" if guid && !uri?(guid)
|
|
90
92
|
problems << "uuid is not a valid UUID" if invalid?(uuid, UUID_PATTERN)
|
|
91
|
-
problems
|
|
93
|
+
problems + module_errors
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def module_errors
|
|
97
|
+
return [] if modules.nil? || modules.all? { |mod| mod.match?(KEBAB_CASE_PATTERN) }
|
|
98
|
+
|
|
99
|
+
["modules must be kebab-case module identifiers"]
|
|
92
100
|
end
|
|
93
101
|
|
|
94
102
|
def invalid?(value, pattern)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Capsium
|
|
4
|
+
class Package
|
|
5
|
+
# OpenPGP support (signatures per ARCHITECTURE.md section 6a and
|
|
6
|
+
# encryption per section 6b) through the rnp gem's binding to
|
|
7
|
+
# librnp.
|
|
8
|
+
#
|
|
9
|
+
# rnp is a soft dependency: it is only required when an OpenPGP
|
|
10
|
+
# feature is actually used, and this module is the single place that
|
|
11
|
+
# loads it. When the rnp gem or the librnp shared library cannot be
|
|
12
|
+
# loaded, the entry points raise OpenPgpUnavailableError with
|
|
13
|
+
# installation guidance.
|
|
14
|
+
module OpenPgp
|
|
15
|
+
# The rnp gem or the librnp shared library is not available.
|
|
16
|
+
class OpenPgpUnavailableError < Capsium::Error; end
|
|
17
|
+
|
|
18
|
+
# A key file is unreadable or holds no suitable OpenPGP key.
|
|
19
|
+
class KeyError < Capsium::Error; end
|
|
20
|
+
|
|
21
|
+
UNAVAILABLE_MESSAGE =
|
|
22
|
+
"OpenPGP support requires librnp and the rnp gem " \
|
|
23
|
+
"(e.g. `brew install rnp` and `gem install rnp`)"
|
|
24
|
+
|
|
25
|
+
# One loaded key file: the Rnp context holding the key material
|
|
26
|
+
# and the selected key within it.
|
|
27
|
+
LoadedKey = Data.define(:rnp, :key)
|
|
28
|
+
|
|
29
|
+
# A fresh Rnp context. Raises OpenPgpUnavailableError when the rnp
|
|
30
|
+
# gem or librnp cannot be loaded.
|
|
31
|
+
def self.rnp
|
|
32
|
+
require "rnp"
|
|
33
|
+
Rnp.new
|
|
34
|
+
rescue LoadError
|
|
35
|
+
raise OpenPgpUnavailableError, UNAVAILABLE_MESSAGE
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Loads the OpenPGP key at key_path (armored or binary, public or
|
|
39
|
+
# secret — the format is auto-detected) into a fresh Rnp context.
|
|
40
|
+
# With secret: true the selected key must hold secret key material
|
|
41
|
+
# (signing, decryption); otherwise any loaded key is selected
|
|
42
|
+
# (verification, encryption). Raises KeyError when the file is
|
|
43
|
+
# unreadable or holds no suitable key.
|
|
44
|
+
def self.load_key(key_path, secret: false)
|
|
45
|
+
instance = rnp
|
|
46
|
+
instance.load_keys(input: Rnp::Input.from_string(File.binread(key_path)),
|
|
47
|
+
format: "GPG")
|
|
48
|
+
key = select_key(instance, secret: secret)
|
|
49
|
+
return LoadedKey.new(rnp: instance, key: key) if key
|
|
50
|
+
|
|
51
|
+
raise KeyError, "no suitable #{secret ? 'secret' : 'public'} OpenPGP key in: #{key_path}"
|
|
52
|
+
rescue Errno::ENOENT, Rnp::Error
|
|
53
|
+
raise KeyError, "cannot load OpenPGP key: #{key_path}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.select_key(instance, secret:)
|
|
57
|
+
keys = instance.keyids.map { |keyid| instance.find_key(keyid: keyid) }
|
|
58
|
+
return keys.find(&:secret_key_present?) if secret
|
|
59
|
+
|
|
60
|
+
keys.first
|
|
61
|
+
end
|
|
62
|
+
private_class_method :select_key
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
|
|
5
|
+
module Capsium
|
|
6
|
+
class Package
|
|
7
|
+
# Encrypts and decrypts whole Capsium packages with OpenPGP key
|
|
8
|
+
# management (ARCHITECTURE.md section 6b) through librnp
|
|
9
|
+
# (Capsium::Package::OpenPgp). Parallel to the RSA-OAEP-SHA256
|
|
10
|
+
# Cipher — same encrypted .cap layout, same AES-256-GCM content
|
|
11
|
+
# encryption, same typed errors — with the DEK carried as an armored
|
|
12
|
+
# OpenPGP message instead of an RSA-wrapped blob:
|
|
13
|
+
#
|
|
14
|
+
# {"encryption": {"algorithm": "AES-256-GCM",
|
|
15
|
+
# "keyManagement": "OpenPGP",
|
|
16
|
+
# "message": <armored OpenPGP message with the DEK>,
|
|
17
|
+
# "iv": <base64>, "authTag": <base64>}}
|
|
18
|
+
#
|
|
19
|
+
# Keys are OpenPGP key files (armored or binary, auto-detected):
|
|
20
|
+
# encryption needs the recipient's public key, decryption the
|
|
21
|
+
# secret key.
|
|
22
|
+
class OpenPgpCipher < Cipher
|
|
23
|
+
KEY_MANAGEMENT = "OpenPGP"
|
|
24
|
+
|
|
25
|
+
# The OpenPGP symmetric cipher protecting the DEK message.
|
|
26
|
+
CIPHER_NAME = "AES256"
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
# The encryption envelope carrying the DEK as an armored OpenPGP
|
|
31
|
+
# message to the recipient's public key.
|
|
32
|
+
def envelope_for(dek, gcm_iv, auth_tag, recipient)
|
|
33
|
+
message = recipient.rnp.encrypt(
|
|
34
|
+
input: Rnp::Input.from_string(dek),
|
|
35
|
+
recipients: recipient.key, cipher: CIPHER_NAME, armored: true
|
|
36
|
+
)
|
|
37
|
+
EncryptionConfig.new(
|
|
38
|
+
encryption: EncryptionEnvelope.new(
|
|
39
|
+
algorithm: ALGORITHM, key_management: KEY_MANAGEMENT,
|
|
40
|
+
message: message,
|
|
41
|
+
iv: Base64.strict_encode64(gcm_iv), auth_tag: Base64.strict_encode64(auth_tag)
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Recovers the DEK by decrypting the envelope's OpenPGP message.
|
|
47
|
+
# Reached only after the rnp binding loaded successfully (the
|
|
48
|
+
# secret key is loaded first), so the Rnp error constants are
|
|
49
|
+
# defined here.
|
|
50
|
+
def unwrap_dek(envelope, secret_key)
|
|
51
|
+
secret_key.rnp.decrypt(input: Rnp::Input.from_string(envelope.message.to_s))
|
|
52
|
+
rescue Rnp::Error
|
|
53
|
+
# No suitable key, or a malformed or tampered message.
|
|
54
|
+
raise DecryptionError, "decryption failed: wrong key or tampered package"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def load_public_key(public_key_path)
|
|
58
|
+
OpenPgp.load_key(public_key_path)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def load_private_key(private_key_path)
|
|
62
|
+
OpenPgp.load_key(private_key_path, secret: true)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Capsium
|
|
4
|
+
class Package
|
|
5
|
+
# Signs and verifies Capsium packages with OpenPGP detached
|
|
6
|
+
# signatures (ARCHITECTURE.md section 6a) through librnp
|
|
7
|
+
# (Capsium::Package::OpenPgp). Parallel to the RSA-SHA256/X.509
|
|
8
|
+
# Signer with the same construction semantics and the same error
|
|
9
|
+
# taxonomy (Signer::SignatureError subclasses), so loaders and the
|
|
10
|
+
# CLI handle both schemes uniformly.
|
|
11
|
+
#
|
|
12
|
+
# The signed payload is identical to the Signer's: the
|
|
13
|
+
# concatenation, in sorted package-relative path order, of the raw
|
|
14
|
+
# bytes of every file covered by the security.json integrity
|
|
15
|
+
# checksums. signature.sig holds the armored detached OpenPGP
|
|
16
|
+
# signature (SHA-256) over that payload; the signer's armored
|
|
17
|
+
# public key is embedded as signature.pub.asc and security.json
|
|
18
|
+
# records:
|
|
19
|
+
#
|
|
20
|
+
# "digitalSignatures": { "certificateType": "OpenPGP",
|
|
21
|
+
# "publicKey": "signature.pub.asc",
|
|
22
|
+
# "signatureFile": "signature.sig" }
|
|
23
|
+
#
|
|
24
|
+
# Keys are OpenPGP key files (armored or binary, auto-detected);
|
|
25
|
+
# signing needs a secret key, verification a public one.
|
|
26
|
+
class OpenPgpSigner
|
|
27
|
+
ALGORITHM = "OpenPGP"
|
|
28
|
+
CERTIFICATE_TYPE = "OpenPGP"
|
|
29
|
+
HASH = "SHA256"
|
|
30
|
+
|
|
31
|
+
# Package-relative name of the embedded armored OpenPGP public key
|
|
32
|
+
# recorded in security.json digitalSignatures.publicKey.
|
|
33
|
+
PUBLIC_KEY_FILE = "signature.pub.asc"
|
|
34
|
+
|
|
35
|
+
attr_reader :package_path
|
|
36
|
+
|
|
37
|
+
def initialize(package_path)
|
|
38
|
+
@package_path = package_path
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Signs a package directory in place, or a .cap file (unpacked,
|
|
42
|
+
# signed, recompressed), with the OpenPGP secret key at
|
|
43
|
+
# secret_key_path. Returns the signed path.
|
|
44
|
+
def self.sign_package(path, secret_key_path)
|
|
45
|
+
return new(path).sign(secret_key_path) unless cap?(path)
|
|
46
|
+
|
|
47
|
+
Packager.new.transform_cap(path) { |dir| new(dir).sign(secret_key_path) }
|
|
48
|
+
path
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Verifies the declared OpenPGP signature of a package directory
|
|
52
|
+
# or .cap file (false on mismatch; raises typed
|
|
53
|
+
# Signer::SignatureError subclasses on structural problems, e.g.
|
|
54
|
+
# an unsigned package). public_key_path is an OpenPGP public key
|
|
55
|
+
# file; without it the key embedded in the package is used.
|
|
56
|
+
def self.verify_package(path, public_key_path = nil)
|
|
57
|
+
return new(path).verify(public_key_path) unless cap?(path)
|
|
58
|
+
|
|
59
|
+
Packager.new.with_unpacked_cap(path) { |dir| new(dir).verify(public_key_path) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.cap?(path)
|
|
63
|
+
File.extname(path) == ".cap"
|
|
64
|
+
end
|
|
65
|
+
private_class_method :cap?
|
|
66
|
+
|
|
67
|
+
# Signs the package directory in place: embeds the armored public
|
|
68
|
+
# key, regenerates security.json (checksums plus digitalSignatures
|
|
69
|
+
# with certificateType "OpenPGP") and writes the armored detached
|
|
70
|
+
# OpenPGP signature to signature.sig.
|
|
71
|
+
def sign(secret_key_path)
|
|
72
|
+
loaded = OpenPgp.load_key(secret_key_path, secret: true)
|
|
73
|
+
File.write(public_key_path, loaded.key.export_public(armored: true))
|
|
74
|
+
security = Security.generate(@package_path, digital_signatures: digital_signatures)
|
|
75
|
+
security.save_to_file
|
|
76
|
+
signature = loaded.rnp.detached_sign(
|
|
77
|
+
input: Rnp::Input.from_string(payload(security)),
|
|
78
|
+
signers: loaded.key, hash: HASH, armored: true
|
|
79
|
+
)
|
|
80
|
+
File.write(signature_path, signature)
|
|
81
|
+
signature_path
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# True when the declared signature verifies against the payload.
|
|
85
|
+
# Without an explicit key path, the embedded public key is used.
|
|
86
|
+
def verify(public_key_path = nil)
|
|
87
|
+
signature = read_signature
|
|
88
|
+
data = payload(declared_security)
|
|
89
|
+
loaded = verification_key(public_key_path)
|
|
90
|
+
signature_matches(loaded, data, signature)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def verify!(public_key_path = nil)
|
|
94
|
+
return true if verify(public_key_path)
|
|
95
|
+
|
|
96
|
+
raise Signer::SignatureMismatchError,
|
|
97
|
+
"digital signature does not match the package contents"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def signed?
|
|
101
|
+
declared_security.signed?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
# Reached only after the rnp binding loaded successfully (the
|
|
107
|
+
# verification key is loaded first), so the Rnp error constants
|
|
108
|
+
# are defined here.
|
|
109
|
+
def signature_matches(loaded, data, signature)
|
|
110
|
+
loaded.rnp.detached_verify(data: Rnp::Input.from_string(data),
|
|
111
|
+
signature: Rnp::Input.from_string(signature))
|
|
112
|
+
true
|
|
113
|
+
rescue Rnp::InvalidSignatureError
|
|
114
|
+
false
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def declared_security
|
|
118
|
+
Security.new(File.join(@package_path, Package::SECURITY_FILE))
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def digital_signatures
|
|
122
|
+
DigitalSignatures.new(certificate_type: CERTIFICATE_TYPE,
|
|
123
|
+
public_key: PUBLIC_KEY_FILE,
|
|
124
|
+
signature_file: Package::SIGNATURE_FILE)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# The canonical signed payload (ARCHITECTURE.md section 6a,
|
|
128
|
+
# identical to Signer's): the concatenation, in sorted
|
|
129
|
+
# package-relative path order, of the bytes of every file covered
|
|
130
|
+
# by the integrity checksums.
|
|
131
|
+
def payload(security)
|
|
132
|
+
security.checksums.keys.sort.map do |relative_path|
|
|
133
|
+
file_path = File.join(@package_path, relative_path)
|
|
134
|
+
unless File.file?(file_path)
|
|
135
|
+
raise Signer::SignatureError,
|
|
136
|
+
"file covered by checksums is missing: #{relative_path}"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
File.binread(file_path)
|
|
140
|
+
end.join
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def read_signature
|
|
144
|
+
unless signed?
|
|
145
|
+
raise Signer::UnsignedPackageError,
|
|
146
|
+
"package is not signed (security.json declares no digitalSignatures)"
|
|
147
|
+
end
|
|
148
|
+
return File.binread(signature_path) if File.file?(signature_path)
|
|
149
|
+
|
|
150
|
+
raise Signer::SignatureError, "signature file missing: #{signature_file_name}"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def signature_file_name
|
|
154
|
+
declared_security.digital_signatures.signature_file || Package::SIGNATURE_FILE
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def signature_path
|
|
158
|
+
File.join(@package_path, signature_file_name)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def public_key_path
|
|
162
|
+
File.join(@package_path, PUBLIC_KEY_FILE)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def verification_key(public_key_path)
|
|
166
|
+
return OpenPgp.load_key(public_key_path) if public_key_path
|
|
167
|
+
|
|
168
|
+
embedded = declared_security.digital_signatures&.public_key || PUBLIC_KEY_FILE
|
|
169
|
+
embedded_path = File.join(@package_path, embedded)
|
|
170
|
+
return OpenPgp.load_key(embedded_path) if File.file?(embedded_path)
|
|
171
|
+
|
|
172
|
+
raise Signer::SignatureError, "public key file missing: #{embedded}"
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -19,13 +19,17 @@ module Capsium
|
|
|
19
19
|
end
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
# The "digitalSignatures" object of security.json.
|
|
23
|
-
#
|
|
22
|
+
# The "digitalSignatures" object of security.json. certificateType
|
|
23
|
+
# selects the signature scheme: absent means RSA-SHA256 (X.509),
|
|
24
|
+
# "OpenPGP" an OpenPGP signature (the publicKey then holds an
|
|
25
|
+
# armored OpenPGP public key).
|
|
24
26
|
class DigitalSignatures < Lutaml::Model::Serializable
|
|
27
|
+
attribute :certificate_type, :string
|
|
25
28
|
attribute :public_key, :string
|
|
26
29
|
attribute :signature_file, :string
|
|
27
30
|
|
|
28
31
|
json do
|
|
32
|
+
map "certificateType", to: :certificate_type
|
|
29
33
|
map "publicKey", to: :public_key
|
|
30
34
|
map "signatureFile", to: :signature_file
|
|
31
35
|
end
|
|
@@ -54,11 +54,26 @@ module Capsium
|
|
|
54
54
|
|
|
55
55
|
# Verifies the declared signature of a package directory or .cap
|
|
56
56
|
# file (false on mismatch; raises typed SignatureError subclasses
|
|
57
|
-
# on structural problems, e.g. an unsigned package).
|
|
57
|
+
# on structural problems, e.g. an unsigned package). The signer is
|
|
58
|
+
# selected from the declared digitalSignatures certificateType:
|
|
59
|
+
# OpenPGP packages verify through OpenPgpSigner.
|
|
58
60
|
def self.verify_package(path, public_key_path = nil)
|
|
59
|
-
return new(path).verify(public_key_path) unless cap?(path)
|
|
61
|
+
return signer_class_for(path).new(path).verify(public_key_path) unless cap?(path)
|
|
60
62
|
|
|
61
|
-
Packager.new.with_unpacked_cap(path)
|
|
63
|
+
Packager.new.with_unpacked_cap(path) do |dir|
|
|
64
|
+
signer_class_for(dir).new(dir).verify(public_key_path)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The signer class matching a package's declared signature scheme:
|
|
69
|
+
# OpenPgpSigner when security.json declares
|
|
70
|
+
# digitalSignatures.certificateType "OpenPGP", Signer otherwise.
|
|
71
|
+
def self.signer_class_for(package_path)
|
|
72
|
+
security = Security.new(File.join(package_path, Package::SECURITY_FILE))
|
|
73
|
+
return OpenPgpSigner if security.digital_signatures&.certificate_type ==
|
|
74
|
+
OpenPgpSigner::CERTIFICATE_TYPE
|
|
75
|
+
|
|
76
|
+
self
|
|
62
77
|
end
|
|
63
78
|
|
|
64
79
|
def self.cap?(path)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "fileutils"
|
|
3
4
|
require "json"
|
|
4
5
|
require "zip"
|
|
5
6
|
|
|
@@ -63,8 +64,34 @@ module Capsium
|
|
|
63
64
|
end
|
|
64
65
|
end
|
|
65
66
|
|
|
67
|
+
# Installs the .cap at source_path into the store as file_name
|
|
68
|
+
# (conventionally "<name>-<version>.cap"), records guid ->
|
|
69
|
+
# file_name in index.json (atomically, tmp + rename) and returns
|
|
70
|
+
# the installed path. Used by registry installs
|
|
71
|
+
# (Capsium::Registry#install).
|
|
72
|
+
def install(source_path, guid:, file_name:)
|
|
73
|
+
destination = File.join(@dir, file_name)
|
|
74
|
+
tmp = "#{destination}.tmp-#{Process.pid}"
|
|
75
|
+
FileUtils.cp(source_path, tmp)
|
|
76
|
+
File.rename(tmp, destination)
|
|
77
|
+
record_index(guid, file_name)
|
|
78
|
+
destination
|
|
79
|
+
end
|
|
80
|
+
|
|
66
81
|
private
|
|
67
82
|
|
|
83
|
+
# Records guid -> file_name in the store's index.json and drops
|
|
84
|
+
# the memoized catalog so the new .cap is found.
|
|
85
|
+
def record_index(guid, file_name)
|
|
86
|
+
index = load_index.merge(guid => file_name)
|
|
87
|
+
path = File.join(@dir, INDEX_FILE)
|
|
88
|
+
tmp = "#{path}.tmp-#{Process.pid}"
|
|
89
|
+
File.write(tmp, JSON.pretty_generate(index))
|
|
90
|
+
File.rename(tmp, path)
|
|
91
|
+
@load_index = index
|
|
92
|
+
@catalog = nil
|
|
93
|
+
end
|
|
94
|
+
|
|
68
95
|
def indexed_path(guid)
|
|
69
96
|
file = load_index[guid]
|
|
70
97
|
file && File.join(@dir, file)
|