capsium 0.2.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 +17 -1
- data/CHANGELOG.md +322 -0
- data/README.adoc +643 -7
- data/capsium.gemspec +3 -0
- data/lib/capsium/cli/formatting.rb +27 -0
- data/lib/capsium/cli/package.rb +106 -7
- data/lib/capsium/cli/reactor.rb +120 -6
- data/lib/capsium/cli.rb +30 -0
- data/lib/capsium/log_buffer.rb +47 -0
- data/lib/capsium/package/authentication.rb +38 -0
- data/lib/capsium/package/authentication_config.rb +78 -0
- data/lib/capsium/package/bundle.rb +136 -0
- data/lib/capsium/package/cipher.rb +253 -0
- data/lib/capsium/package/composition.rb +93 -0
- data/lib/capsium/package/dataset.rb +20 -0
- data/lib/capsium/package/dependency_resolver.rb +91 -0
- data/lib/capsium/package/encryption_config.rb +42 -0
- data/lib/capsium/package/manifest.rb +1 -1
- data/lib/capsium/package/merged_view.rb +170 -0
- 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/preparation.rb +81 -0
- data/lib/capsium/package/routes_config.rb +60 -6
- data/lib/capsium/package/security.rb +21 -4
- data/lib/capsium/package/security_config.rb +6 -2
- data/lib/capsium/package/signer.rb +216 -0
- data/lib/capsium/package/storage_config.rb +29 -1
- data/lib/capsium/package/store.rb +136 -0
- data/lib/capsium/package/testing/config_test.rb +70 -0
- data/lib/capsium/package/testing/context.rb +11 -0
- data/lib/capsium/package/testing/data_validation_test.rb +63 -0
- data/lib/capsium/package/testing/file_test.rb +45 -0
- data/lib/capsium/package/testing/report.rb +33 -0
- data/lib/capsium/package/testing/route_test.rb +64 -0
- data/lib/capsium/package/testing/test_case.rb +60 -0
- data/lib/capsium/package/testing/test_suite.rb +86 -0
- data/lib/capsium/package/testing.rb +20 -0
- data/lib/capsium/package/validator.rb +39 -3
- data/lib/capsium/package/verification.rb +34 -0
- data/lib/capsium/package/version.rb +204 -0
- data/lib/capsium/package.rb +78 -75
- data/lib/capsium/packager.rb +97 -22
- data/lib/capsium/reactor/authenticator.rb +180 -0
- data/lib/capsium/reactor/content_api.rb +110 -0
- data/lib/capsium/reactor/data_api.rb +154 -0
- data/lib/capsium/reactor/deploy.rb +71 -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/htpasswd.rb +154 -0
- data/lib/capsium/reactor/introspection.rb +151 -31
- data/lib/capsium/reactor/metrics.rb +30 -0
- data/lib/capsium/reactor/mount.rb +206 -0
- data/lib/capsium/reactor/oauth2.rb +106 -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 +154 -0
- data/lib/capsium/reactor/session.rb +82 -0
- data/lib/capsium/reactor.rb +116 -67
- 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/thor_ext.rb +1 -1
- 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/authentication.rbs +23 -0
- data/sig/capsium/package/authentication_config.rbs +71 -0
- data/sig/capsium/package/bundle.rbs +43 -0
- data/sig/capsium/package/cipher.rbs +62 -0
- data/sig/capsium/package/composition.rbs +21 -0
- data/sig/capsium/package/dataset.rbs +8 -0
- data/sig/capsium/package/dependency_resolver.rbs +53 -0
- data/sig/capsium/package/encryption_config.rbs +39 -0
- data/sig/capsium/package/merged_view.rbs +47 -0
- 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/preparation.rbs +23 -0
- data/sig/capsium/package/routes_config.rbs +36 -3
- data/sig/capsium/package/security.rbs +9 -2
- data/sig/capsium/package/security_config.rbs +6 -3
- data/sig/capsium/package/signer.rbs +66 -0
- data/sig/capsium/package/storage_config.rbs +26 -2
- data/sig/capsium/package/store.rbs +43 -0
- data/sig/capsium/package/testing/config_test.rbs +21 -0
- data/sig/capsium/package/testing/context.rbs +14 -0
- data/sig/capsium/package/testing/data_validation_test.rbs +22 -0
- data/sig/capsium/package/testing/file_test.rbs +17 -0
- data/sig/capsium/package/testing/report.rbs +20 -0
- data/sig/capsium/package/testing/route_test.rbs +22 -0
- data/sig/capsium/package/testing/test_case.rbs +41 -0
- data/sig/capsium/package/testing/test_suite.rbs +20 -0
- data/sig/capsium/package/testing.rbs +7 -0
- data/sig/capsium/package/verification.rbs +26 -0
- data/sig/capsium/package/version.rbs +39 -0
- data/sig/capsium/package.rbs +29 -16
- data/sig/capsium/packager.rbs +9 -0
- data/sig/capsium/reactor/authenticator.rbs +40 -0
- data/sig/capsium/reactor/content_api.rbs +19 -0
- data/sig/capsium/reactor/data_api.rbs +23 -0
- data/sig/capsium/reactor/deploy.rbs +34 -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/htpasswd.rbs +36 -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/oauth2.rbs +31 -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 +46 -0
- data/sig/capsium/reactor/session.rbs +36 -0
- data/sig/capsium/reactor.rbs +22 -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 +135 -1
|
@@ -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
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
module Capsium
|
|
7
|
+
class Package
|
|
8
|
+
# Package-source preparation (ARCHITECTURE.md section 1), mixed into
|
|
9
|
+
# Package: directory/.cap/encrypted-cap detection, extraction and
|
|
10
|
+
# decryption into a readable package directory.
|
|
11
|
+
module Preparation
|
|
12
|
+
def prepare_package(path)
|
|
13
|
+
return decrypt_cap_file(path) if Cipher.encrypted?(path)
|
|
14
|
+
return path if File.directory?(path)
|
|
15
|
+
raise Error, "Invalid package path: #{path}" unless File.file?(path)
|
|
16
|
+
raise Error, "The package must have a .cap extension" unless File.extname(path) == ".cap"
|
|
17
|
+
|
|
18
|
+
decompress_cap_file(path)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def decompress_cap_file(file_path)
|
|
22
|
+
package_path = File.join(Dir.mktmpdir, package_stem(file_path))
|
|
23
|
+
FileUtils.mkdir_p(package_path)
|
|
24
|
+
Packager.new.unpack(file_path, package_path)
|
|
25
|
+
package_path
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Decrypts an encrypted package (.cap file or uncompressed directory)
|
|
29
|
+
# into a temporary directory and returns its path. The metadata.json
|
|
30
|
+
# of an encrypted package stays cleartext, but everything else is
|
|
31
|
+
# only readable with the recipient's private key.
|
|
32
|
+
def decrypt_cap_file(source_path)
|
|
33
|
+
unless @decryption_key
|
|
34
|
+
raise Cipher::KeyRequiredError,
|
|
35
|
+
"Package is encrypted; provide the private key via decryption_key:"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
Cipher.decrypt_to_directory(source_path, @decryption_key)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def determine_load_type(path)
|
|
42
|
+
return :directory if File.directory?(path)
|
|
43
|
+
|
|
44
|
+
File.extname(path) == ".cap" ? :cap_file : :unsaved
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Whether the package was loaded from an encrypted source
|
|
48
|
+
# (ARCHITECTURE.md section 6b layout).
|
|
49
|
+
def encrypted?
|
|
50
|
+
Cipher.encrypted?(@original_path)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def package_stem(file_path)
|
|
56
|
+
File.basename(file_path, ".cap")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def create_package_structure
|
|
60
|
+
FileUtils.mkdir_p(content_path)
|
|
61
|
+
FileUtils.mkdir_p(data_path)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def content_path = File.join(@path, CONTENT_DIR)
|
|
65
|
+
|
|
66
|
+
def data_path = File.join(@path, DATA_DIR)
|
|
67
|
+
|
|
68
|
+
def routes_path = File.join(@path, ROUTES_FILE)
|
|
69
|
+
|
|
70
|
+
def storage_path = File.join(@path, STORAGE_FILE)
|
|
71
|
+
|
|
72
|
+
def metadata_path = File.join(@path, METADATA_FILE)
|
|
73
|
+
|
|
74
|
+
def manifest_path = File.join(@path, MANIFEST_FILE)
|
|
75
|
+
|
|
76
|
+
def security_path = File.join(@path, SECURITY_FILE)
|
|
77
|
+
|
|
78
|
+
def authentication_path = File.join(@path, AUTHENTICATION_FILE)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -5,12 +5,33 @@ require "lutaml/model"
|
|
|
5
5
|
|
|
6
6
|
module Capsium
|
|
7
7
|
class Package
|
|
8
|
+
# The "responseRewrite" object of an inherited route (05x-routing
|
|
9
|
+
# section "Route Inheritance"): replaces the served body and/or
|
|
10
|
+
# overrides response headers.
|
|
11
|
+
class ResponseRewrite < Lutaml::Model::Serializable
|
|
12
|
+
attribute :body, :string
|
|
13
|
+
attribute :headers, :hash
|
|
14
|
+
|
|
15
|
+
json do
|
|
16
|
+
map :body, to: :body
|
|
17
|
+
map :headers, to: :headers
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
8
21
|
# A single route entry (ARCHITECTURE.md section 4). Kinds are
|
|
9
22
|
# discriminated by key, MECE:
|
|
10
23
|
# - {path, resource, headers?, visibility?} -- static file
|
|
11
24
|
# - {path, dataset, accessControl?} -- dataset route
|
|
12
25
|
# - {path, method, handler, ...} -- dynamic handler (parsed,
|
|
13
26
|
# accepted-and-ignored; reactors respond 501)
|
|
27
|
+
#
|
|
28
|
+
# Route inheritance (05x-routing): a resource of the form
|
|
29
|
+
# "<dependency-guid>/<path>" (a URI, i.e. containing "://") pulls
|
|
30
|
+
# content from a dependency package; "remap" replaces the serving
|
|
31
|
+
# path; "responseRewrite"/"responseHeaders" post-process the
|
|
32
|
+
# response; "requestHeaders" are recorded for forwarding reactors
|
|
33
|
+
# (the Ruby reactor serves statically and does not forward, so they
|
|
34
|
+
# do not alter its responses).
|
|
14
35
|
class Route < Lutaml::Model::Serializable
|
|
15
36
|
DATASET_PATH_PREFIX = "/api/v1/data/"
|
|
16
37
|
|
|
@@ -23,6 +44,10 @@ module Capsium
|
|
|
23
44
|
attribute :access_control, :hash
|
|
24
45
|
attribute :http_method, :string
|
|
25
46
|
attribute :handler, :string
|
|
47
|
+
attribute :remap, :string
|
|
48
|
+
attribute :response_rewrite, ResponseRewrite
|
|
49
|
+
attribute :response_headers, :hash
|
|
50
|
+
attribute :request_headers, :hash
|
|
26
51
|
|
|
27
52
|
json do
|
|
28
53
|
map :path, to: :path
|
|
@@ -34,6 +59,10 @@ module Capsium
|
|
|
34
59
|
map "accessControl", to: :access_control
|
|
35
60
|
map "method", to: :http_method
|
|
36
61
|
map :handler, to: :handler
|
|
62
|
+
map :remap, to: :remap
|
|
63
|
+
map "responseRewrite", to: :response_rewrite
|
|
64
|
+
map "responseHeaders", to: :response_headers
|
|
65
|
+
map "requestHeaders", to: :request_headers
|
|
37
66
|
end
|
|
38
67
|
|
|
39
68
|
def kind
|
|
@@ -51,6 +80,25 @@ module Capsium
|
|
|
51
80
|
kind == :handler
|
|
52
81
|
end
|
|
53
82
|
|
|
83
|
+
# The URL path this route answers at: the remapped path when the
|
|
84
|
+
# route remaps an inherited route, its own path otherwise.
|
|
85
|
+
def serving_path
|
|
86
|
+
remap || path
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Whether the resource addresses content of a dependency package
|
|
90
|
+
# ("<dependency-guid>/<path>" — a URI rather than a
|
|
91
|
+
# package-relative path).
|
|
92
|
+
def dependency_reference?
|
|
93
|
+
resource.is_a?(String) && resource.include?("://")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Whether the route carries route-inheritance attributes.
|
|
97
|
+
def inherited?
|
|
98
|
+
dependency_reference? || !remap.nil? || !response_rewrite.nil? ||
|
|
99
|
+
!response_headers.nil? || !request_headers.nil?
|
|
100
|
+
end
|
|
101
|
+
|
|
54
102
|
def fs_path(package_path)
|
|
55
103
|
return unless resource
|
|
56
104
|
|
|
@@ -61,22 +109,28 @@ module Capsium
|
|
|
61
109
|
manifest.type_for(resource)
|
|
62
110
|
end
|
|
63
111
|
|
|
64
|
-
def validate_target(package_path, storage)
|
|
112
|
+
def validate_target(package_path, storage, merged_view: nil)
|
|
65
113
|
return if handler_route?
|
|
114
|
+
return if dependency_reference?
|
|
66
115
|
|
|
67
116
|
if dataset_route?
|
|
68
117
|
return if storage.dataset(dataset)
|
|
69
118
|
|
|
70
119
|
raise Error, "Route dataset does not exist: #{dataset}"
|
|
71
120
|
end
|
|
72
|
-
validate_resource_target(package_path)
|
|
121
|
+
validate_resource_target(package_path, merged_view)
|
|
73
122
|
end
|
|
74
123
|
|
|
75
124
|
private
|
|
76
125
|
|
|
77
|
-
def validate_resource_target(package_path)
|
|
78
|
-
|
|
79
|
-
|
|
126
|
+
def validate_resource_target(package_path, merged_view)
|
|
127
|
+
found = if merged_view
|
|
128
|
+
merged_view.resolve(resource)
|
|
129
|
+
else
|
|
130
|
+
target_path = fs_path(package_path)
|
|
131
|
+
target_path && File.exist?(target_path)
|
|
132
|
+
end
|
|
133
|
+
return if found
|
|
80
134
|
|
|
81
135
|
raise Error, "Route resource does not exist: #{resource}"
|
|
82
136
|
end
|
|
@@ -121,7 +175,7 @@ module Capsium
|
|
|
121
175
|
end
|
|
122
176
|
|
|
123
177
|
def resolve(path)
|
|
124
|
-
routes.detect { |route| route.
|
|
178
|
+
routes.detect { |route| route.serving_path == path }
|
|
125
179
|
end
|
|
126
180
|
|
|
127
181
|
def add(path, target)
|
|
@@ -8,7 +8,8 @@ module Capsium
|
|
|
8
8
|
class Package
|
|
9
9
|
# Loads, generates and verifies security.json (ARCHITECTURE.md
|
|
10
10
|
# section 6). Checksums cover every file in the package except
|
|
11
|
-
# security.json itself.
|
|
11
|
+
# security.json itself and signature.sig (the signature signs the
|
|
12
|
+
# checksum-covered payload, so it cannot be part of it).
|
|
12
13
|
class Security
|
|
13
14
|
extend Forwardable
|
|
14
15
|
|
|
@@ -39,13 +40,14 @@ module Capsium
|
|
|
39
40
|
@config = config || load_config
|
|
40
41
|
end
|
|
41
42
|
|
|
42
|
-
def self.generate(package_path)
|
|
43
|
+
def self.generate(package_path, digital_signatures: nil)
|
|
43
44
|
config = SecurityConfig.new(
|
|
44
45
|
security: SecurityData.new(
|
|
45
46
|
integrity_checks: IntegrityChecks.new(
|
|
46
47
|
checksum_algorithm: "SHA-256",
|
|
47
48
|
checksums: checksums_for(package_path)
|
|
48
|
-
)
|
|
49
|
+
),
|
|
50
|
+
digital_signatures: digital_signatures
|
|
49
51
|
)
|
|
50
52
|
)
|
|
51
53
|
new(File.join(package_path, SECURITY_FILE), config)
|
|
@@ -62,13 +64,28 @@ module Capsium
|
|
|
62
64
|
File.file?(file)
|
|
63
65
|
end
|
|
64
66
|
files.map { |file| file.delete_prefix("#{package_path}/") }
|
|
65
|
-
.reject { |relative_path| relative_path
|
|
67
|
+
.reject { |relative_path| excluded?(relative_path) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.excluded?(relative_path)
|
|
71
|
+
[SECURITY_FILE, SIGNATURE_FILE].include?(relative_path)
|
|
66
72
|
end
|
|
67
73
|
|
|
68
74
|
def present?
|
|
69
75
|
!@config.nil?
|
|
70
76
|
end
|
|
71
77
|
|
|
78
|
+
# The declared digitalSignatures block, or nil when absent.
|
|
79
|
+
def digital_signatures
|
|
80
|
+
return unless present? && config.security
|
|
81
|
+
|
|
82
|
+
config.security.digital_signatures
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def signed?
|
|
86
|
+
!digital_signatures&.signature_file.nil?
|
|
87
|
+
end
|
|
88
|
+
|
|
72
89
|
def checksums
|
|
73
90
|
present? ? config.checksums : {}
|
|
74
91
|
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
|