capsium 0.3.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +7 -3
  3. data/CHANGELOG.md +197 -0
  4. data/README.adoc +323 -14
  5. data/capsium.gemspec +2 -0
  6. data/lib/capsium/cli/package.rb +62 -25
  7. data/lib/capsium/cli/reactor.rb +112 -6
  8. data/lib/capsium/cli.rb +29 -0
  9. data/lib/capsium/log_buffer.rb +47 -0
  10. data/lib/capsium/package/bundle.rb +136 -0
  11. data/lib/capsium/package/cipher.rb +73 -17
  12. data/lib/capsium/package/composition.rb +28 -12
  13. data/lib/capsium/package/dataset.rb +20 -0
  14. data/lib/capsium/package/dependency_resolver.rb +39 -9
  15. data/lib/capsium/package/encryption_config.rb +7 -3
  16. data/lib/capsium/package/manifest.rb +1 -1
  17. data/lib/capsium/package/merged_view.rb +4 -2
  18. data/lib/capsium/package/metadata.rb +1 -1
  19. data/lib/capsium/package/metadata_config.rb +9 -1
  20. data/lib/capsium/package/open_pgp.rb +65 -0
  21. data/lib/capsium/package/open_pgp_cipher.rb +66 -0
  22. data/lib/capsium/package/open_pgp_signer.rb +176 -0
  23. data/lib/capsium/package/security_config.rb +6 -2
  24. data/lib/capsium/package/signer.rb +18 -3
  25. data/lib/capsium/package/store.rb +27 -0
  26. data/lib/capsium/package/validator.rb +27 -2
  27. data/lib/capsium/package/verification.rb +7 -5
  28. data/lib/capsium/package.rb +24 -5
  29. data/lib/capsium/packager.rb +64 -22
  30. data/lib/capsium/reactor/content_api.rb +110 -0
  31. data/lib/capsium/reactor/data_api.rb +154 -0
  32. data/lib/capsium/reactor/endpoints.rb +37 -0
  33. data/lib/capsium/reactor/graphql_api.rb +123 -0
  34. data/lib/capsium/reactor/graphql_schema.rb +138 -0
  35. data/lib/capsium/reactor/introspection.rb +151 -34
  36. data/lib/capsium/reactor/metrics.rb +30 -0
  37. data/lib/capsium/reactor/mount.rb +206 -0
  38. data/lib/capsium/reactor/overlay.rb +248 -0
  39. data/lib/capsium/reactor/package_saver.rb +99 -0
  40. data/lib/capsium/reactor/responses.rb +41 -0
  41. data/lib/capsium/reactor/serving.rb +90 -11
  42. data/lib/capsium/reactor.rb +96 -49
  43. data/lib/capsium/registry/local.rb +120 -0
  44. data/lib/capsium/registry/remote.rb +105 -0
  45. data/lib/capsium/registry.rb +190 -0
  46. data/lib/capsium/version.rb +1 -1
  47. data/lib/capsium.rb +2 -0
  48. data/sig/capsium/log_buffer.rbs +30 -0
  49. data/sig/capsium/package/bundle.rbs +43 -0
  50. data/sig/capsium/package/cipher.rbs +12 -1
  51. data/sig/capsium/package/composition.rbs +4 -3
  52. data/sig/capsium/package/dataset.rbs +8 -0
  53. data/sig/capsium/package/dependency_resolver.rbs +8 -4
  54. data/sig/capsium/package/encryption_config.rbs +6 -2
  55. data/sig/capsium/package/merged_view.rbs +2 -1
  56. data/sig/capsium/package/metadata.rbs +1 -0
  57. data/sig/capsium/package/open_pgp.rbs +38 -0
  58. data/sig/capsium/package/open_pgp_cipher.rbs +15 -0
  59. data/sig/capsium/package/open_pgp_signer.rbs +46 -0
  60. data/sig/capsium/package/security_config.rbs +6 -3
  61. data/sig/capsium/package/signer.rbs +8 -1
  62. data/sig/capsium/package/store.rbs +6 -0
  63. data/sig/capsium/package/verification.rbs +5 -3
  64. data/sig/capsium/package.rbs +8 -3
  65. data/sig/capsium/reactor/content_api.rbs +19 -0
  66. data/sig/capsium/reactor/data_api.rbs +23 -0
  67. data/sig/capsium/reactor/endpoints.rbs +16 -0
  68. data/sig/capsium/reactor/graphql_api.rbs +27 -0
  69. data/sig/capsium/reactor/graphql_schema.rbs +20 -0
  70. data/sig/capsium/reactor/introspection.rbs +27 -7
  71. data/sig/capsium/reactor/metrics.rbs +13 -0
  72. data/sig/capsium/reactor/mount.rbs +83 -0
  73. data/sig/capsium/reactor/overlay.rbs +72 -0
  74. data/sig/capsium/reactor/package_saver.rbs +12 -0
  75. data/sig/capsium/reactor/responses.rbs +23 -0
  76. data/sig/capsium/reactor/serving.rbs +28 -4
  77. data/sig/capsium/reactor.rbs +17 -2
  78. data/sig/capsium/registry/local.rbs +16 -0
  79. data/sig/capsium/registry/remote.rbs +18 -0
  80. data/sig/capsium/registry.rbs +83 -0
  81. data/sig/graphql.rbs +12 -0
  82. metadata +66 -1
@@ -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 against a package store
7
- # and load-time validation of dependency resource references.
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 against the package store (the
12
- # `store` argument, else CAPSIUM_STORE). Each resolved dependency
13
- # loads recursively with an extended circularity chain; its
14
- # exported content becomes lower read-only layers of this
15
- # package's merged view.
16
- def resolve_dependencies(store, chain)
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 = resolver.resolve_path(guid, range, chain: chain)
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, store: resolver.store,
27
- dependency_chain: chain)
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. Package loads the
32
- # resolved .cap itself (recursively, with an extended chain).
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. Raises
47
- # CircularDependencyError when the GUID is already being resolved
48
- # up-chain, DependencyNotFoundError when the store has no package
49
- # for it and UnsatisfiableDependencyError when no stored version
50
- # satisfies the range.
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 with the
9
- # RSA-OAEP-SHA256 wrapped data encryption key (DEK), all binary
10
- # values Base64-encoded.
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
@@ -71,7 +71,7 @@ module Capsium
71
71
  private
72
72
 
73
73
  def content_files
74
- Dir.glob(File.join(@content_path, "**", "*")).select do |file|
74
+ Dir.glob(File.join(@content_path, "**", "*"), File::FNM_DOTMATCH).select do |file|
75
75
  File.file?(file)
76
76
  end
77
77
  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
@@ -11,7 +11,7 @@ module Capsium
11
11
 
12
12
  def_delegators :@config, :to_json, :to_hash, :name, :version,
13
13
  :description, :guid, :uuid, :author, :license,
14
- :repository, :dependencies
14
+ :repository, :dependencies, :read_only
15
15
 
16
16
  def initialize(path)
17
17
  @path = path
@@ -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. Signing is a later
23
- # phase; the model parses the canonical fields only.
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) { |dir| new(dir).verify(public_key_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)