diff --git a/app/controllers/api/v1/licenses/actions/checkouts_controller.rb b/app/controllers/api/v1/licenses/actions/checkouts_controller.rb index 90ccaa4e6e..e3bebc9898 100644 --- a/app/controllers/api/v1/licenses/actions/checkouts_controller.rb +++ b/app/controllers/api/v1/licenses/actions/checkouts_controller.rb @@ -11,6 +11,7 @@ class CheckoutsController < Api::V1::BaseController typed_query { param :encrypt, type: :boolean, coerce: true, optional: true + param :algorithm, type: :string, allow_blank: true, optional: true param :ttl, type: :integer, coerce: true, allow_nil: true, optional: true param :include, type: :array, coerce: true, allow_blank: true, optional: true, transform: -> key, includes { # FIXME(ezekg) For backwards compatibility. Replace user include with @@ -22,6 +23,7 @@ class CheckoutsController < Api::V1::BaseController } def show kwargs = checkout_query.slice( + :algorithm, :include, :encrypt, :ttl, @@ -33,6 +35,8 @@ def show response.headers['Content-Type'] = 'application/octet-stream' render body: license_file.certificate + rescue LicenseCheckoutService::InvalidAlgorithmError => e + render_bad_request detail: e.message, code: :CHECKOUT_ALGORITHM_INVALID, source: { parameter: :algorithm } rescue LicenseCheckoutService::InvalidIncludeError => e render_bad_request detail: e.message, code: :CHECKOUT_INCLUDE_INVALID, source: { parameter: :include } rescue LicenseCheckoutService::InvalidTTLError => e @@ -46,6 +50,7 @@ def show param :meta, type: :hash, optional: true do param :encrypt, type: :boolean, optional: true + param :algorithm, type: :string, allow_blank: true, allow_nil: true, optional: true param :ttl, type: :integer, coerce: true, allow_nil: true, optional: true param :include, type: :array, allow_blank: true, optional: true, transform: -> key, includes { includes.push('owner') if includes.delete('user') @@ -56,6 +61,7 @@ def show } typed_query { param :encrypt, type: :boolean, coerce: true, optional: true + param :algorithm, type: :string, allow_blank: true, optional: true param :ttl, type: :integer, coerce: true, allow_nil: true, optional: true param :include, type: :array, coerce: true, allow_blank: true, optional: true, transform: -> key, includes { includes.push('owner') if includes.delete('user') @@ -66,6 +72,7 @@ def show def create kwargs = checkout_query.merge(checkout_meta) .slice( + :algorithm, :include, :encrypt, :ttl, @@ -74,6 +81,8 @@ def create license_file = checkout_license_file(**kwargs) render jsonapi: license_file + rescue LicenseCheckoutService::InvalidAlgorithmError => e + render_bad_request detail: e.message, code: :CHECKOUT_ALGORITHM_INVALID, source: { parameter: :algorithm } rescue LicenseCheckoutService::InvalidIncludeError => e render_bad_request detail: e.message, code: :CHECKOUT_INCLUDE_INVALID, source: { parameter: :include } rescue LicenseCheckoutService::InvalidTTLError => e diff --git a/app/controllers/api/v1/machines/actions/checkouts_controller.rb b/app/controllers/api/v1/machines/actions/checkouts_controller.rb index 461ea32f22..73cb932aef 100644 --- a/app/controllers/api/v1/machines/actions/checkouts_controller.rb +++ b/app/controllers/api/v1/machines/actions/checkouts_controller.rb @@ -11,6 +11,7 @@ class CheckoutsController < Api::V1::BaseController typed_query { param :encrypt, type: :boolean, coerce: true, optional: true + param :algorithm, type: :string, allow_blank: true, optional: true param :ttl, type: :integer, coerce: true, allow_nil: true, optional: true param :include, type: :array, coerce: true, allow_blank: true, optional: true, transform: -> key, includes { # FIXME(ezekg) For backwards compatibility. Replace license.user include with @@ -22,6 +23,7 @@ class CheckoutsController < Api::V1::BaseController } def show kwargs = checkout_query.slice( + :algorithm, :include, :encrypt, :ttl, @@ -33,6 +35,8 @@ def show response.headers['Content-Type'] = 'application/octet-stream' render body: machine_file.certificate + rescue MachineCheckoutService::InvalidAlgorithmError => e + render_bad_request detail: e.message, code: :CHECKOUT_ALGORITHM_INVALID, source: { parameter: :algorithm } rescue MachineCheckoutService::InvalidIncludeError => e render_bad_request detail: e.message, code: :CHECKOUT_INCLUDE_INVALID, source: { parameter: :include } rescue MachineCheckoutService::InvalidTTLError => e @@ -46,6 +50,7 @@ def show param :meta, type: :hash, optional: true do param :encrypt, type: :boolean, optional: true + param :algorithm, type: :string, allow_blank: true, allow_nil: true, optional: true param :ttl, type: :integer, coerce: true, allow_nil: true, optional: true param :include, type: :array, allow_blank: true, optional: true, transform: -> key, includes { includes.push('license.owner') if includes.delete('license.user') @@ -56,6 +61,7 @@ def show } typed_query { param :encrypt, type: :boolean, coerce: true, optional: true + param :algorithm, type: :string, allow_blank: true, optional: true param :ttl, type: :integer, coerce: true, allow_nil: true, optional: true param :include, type: :array, coerce: true, allow_blank: true, optional: true, transform: -> key, includes { includes.push('license.owner') if includes.delete('license.user') @@ -66,6 +72,7 @@ def show def create kwargs = checkout_query.merge(checkout_meta) .slice( + :algorithm, :include, :encrypt, :ttl, @@ -74,6 +81,8 @@ def create machine_file = checkout_machine_file(**kwargs) render jsonapi: machine_file + rescue MachineCheckoutService::InvalidAlgorithmError => e + render_bad_request detail: e.message, code: :CHECKOUT_ALGORITHM_INVALID, source: { parameter: :algorithm } rescue MachineCheckoutService::InvalidIncludeError => e render_bad_request detail: e.message, code: :CHECKOUT_INCLUDE_INVALID, source: { parameter: :include } rescue MachineCheckoutService::InvalidTTLError => e diff --git a/app/models/license_file.rb b/app/models/license_file.rb index b8f23f0406..169cbf5165 100644 --- a/app/models/license_file.rb +++ b/app/models/license_file.rb @@ -4,6 +4,15 @@ class LicenseFile include ActiveModel::Model include ActiveModel::Attributes + ALGORITHMS = %w[ + aes-256-gcm+ed25519 + aes-256-gcm+rsa-pss-sha256 + aes-256-gcm+rsa-sha256 + base64+ed25519 + base64+rsa-pss-sha256 + base64+rsa-sha256 + ].freeze + attribute :account_id, :uuid attribute :environment_id, :uuid attribute :license_id, :uuid @@ -12,6 +21,7 @@ class LicenseFile attribute :expires_at, :datetime attribute :ttl, :integer attribute :includes, :array, default: [] + attribute :algorithm, :string validates :account_id, presence: true validates :license_id, presence: true @@ -29,6 +39,9 @@ class LicenseFile greater_than_or_equal_to: 1.hour, allow_nil: true + validates_inclusion_of :algorithm, + in: ALGORITHMS + def persisted? = false def id = @id ||= SecureRandom.uuid def product = @product ||= license&.product diff --git a/app/models/machine_file.rb b/app/models/machine_file.rb index 5a6bd4d8ec..fd8d20a7e0 100644 --- a/app/models/machine_file.rb +++ b/app/models/machine_file.rb @@ -4,6 +4,15 @@ class MachineFile include ActiveModel::Model include ActiveModel::Attributes + ALGORITHMS = %w[ + aes-256-gcm+ed25519 + aes-256-gcm+rsa-pss-sha256 + aes-256-gcm+rsa-sha256 + base64+ed25519 + base64+rsa-pss-sha256 + base64+rsa-sha256 + ].freeze + attribute :account_id, :uuid attribute :environment_id, :uuid attribute :license_id, :uuid @@ -13,6 +22,7 @@ class MachineFile attribute :expires_at, :datetime attribute :ttl, :integer attribute :includes, :array, default: [] + attribute :algorithm, :string validates :account_id, presence: true validates :license_id, presence: true @@ -31,6 +41,9 @@ class MachineFile greater_than_or_equal_to: 1.hour, allow_nil: true + validates_inclusion_of :algorithm, + in: ALGORITHMS + def persisted? = false def id = @id ||= SecureRandom.uuid def product = @product ||= license&.product diff --git a/app/serializers/license_file_serializer.rb b/app/serializers/license_file_serializer.rb index 94c83c5182..a85bc66957 100644 --- a/app/serializers/license_file_serializer.rb +++ b/app/serializers/license_file_serializer.rb @@ -4,6 +4,7 @@ class LicenseFileSerializer < BaseSerializer type 'license-files' attribute :certificate + attribute :algorithm attribute :includes attribute :ttl attribute :expiry do diff --git a/app/serializers/machine_file_serializer.rb b/app/serializers/machine_file_serializer.rb index ca14ceab32..9097bfe1a7 100644 --- a/app/serializers/machine_file_serializer.rb +++ b/app/serializers/machine_file_serializer.rb @@ -4,6 +4,7 @@ class MachineFileSerializer < BaseSerializer type 'machine-files' attribute :certificate + attribute :algorithm attribute :includes attribute :ttl attribute :expiry do diff --git a/app/services/abstract_checkout_service.rb b/app/services/abstract_checkout_service.rb index b2ac00d97d..531214d3bf 100644 --- a/app/services/abstract_checkout_service.rb +++ b/app/services/abstract_checkout_service.rb @@ -5,50 +5,52 @@ class InvalidAccountError < StandardError; end class InvalidAlgorithmError < StandardError; end class InvalidTTLError < StandardError; end - ENCRYPT_ALGORITHM = 'aes-256-gcm'.freeze - ENCODE_ALGORITHM = 'base64'.freeze - ALLOWED_ALGORITHMS = %w[ + DEFAULT_ENCRYPTION_ALGORITHM = 'aes-256-gcm'.freeze + DEFAULT_ENCODING_ALGORITHM = 'base64'.freeze + DEFAULT_SIGNING_ALGORITHM = 'ed25519'.freeze + + ENCRYPTION_ALGORITHMS = %w[aes-256-gcm].freeze + ENCODING_ALGORITHMS = %w[base64].freeze + SIGNING_ALGORITHMS = %w[ ed25519 rsa-pss-sha256 rsa-sha256 ].freeze - def initialize(account:, scheme: nil, encrypt: false, ttl: 1.month, include: [], api_version: nil) - raise InvalidAccountError, 'license must be present' unless + def initialize(account:, encrypt: false, sign: true, algorithm: nil, ttl: 1.month, include: [], api_version: nil) + raise InvalidAccountError, 'account must be present' unless account.present? raise InvalidTTLError, 'must be greater than or equal to 3600 (1 hour)' if ttl.present? && ttl < 1.hour + raise InvalidAlgorithmError, 'algorithm must be present' if + algorithm.present? && algorithm.blank? + + @algorithm = algorithm.presence || begin + enc = encrypt ? DEFAULT_ENCRYPTION_ALGORITHM : DEFAULT_ENCODING_ALGORITHM + sig = sign == true || sign.blank? ? DEFAULT_SIGNING_ALGORITHM : sign + + "#{enc}+#{sig}" + end + + raise InvalidAlgorithmError, 'invalid encoding algorithm' unless + encryption_algorithm.in?(ENCRYPTION_ALGORITHMS) || + encoding_algorithm.in?(ENCODING_ALGORITHMS) + + raise InvalidAlgorithmError, 'invalid signing algorithm' unless + signing_algorithm.in?(SIGNING_ALGORITHMS) + @renderer = Keygen::JSONAPI::Renderer.new(account:, api_version:, context: :checkout) @account = account - @encrypted = encrypt @ttl = ttl @includes = include - @private_key = case scheme - when 'RSA_2048_PKCS1_PSS_SIGN_V2', - 'RSA_2048_PKCS1_SIGN_V2', - 'RSA_2048_PKCS1_PSS_SIGN', - 'RSA_2048_PKCS1_SIGN', - 'RSA_2048_PKCS1_ENCRYPT', - 'RSA_2048_JWT_RS256' - account.private_key - else + @private_key = case + when ed25519? account.ed25519_private_key + when rsa? + account.private_key end - - @algorithm = case scheme - when 'RSA_2048_PKCS1_PSS_SIGN_V2', - 'RSA_2048_PKCS1_PSS_SIGN' - 'rsa-pss-sha256' - when 'RSA_2048_PKCS1_SIGN_V2', - 'RSA_2048_PKCS1_SIGN', - 'RSA_2048_PKCS1_ENCRYPT', - 'RSA_2048_JWT_RS256' - 'rsa-sha256' - else - 'ed25519' - end end def call @@ -60,25 +62,23 @@ def call attr_reader :renderer, :private_key, :algorithm, - :encrypted, :ttl, :includes, :account - def encrypted? - !!encrypted - end + def algorithm_parts = @algorithm_parts ||= algorithm.split('+', 2) + def encryption_algorithm = @encryption_algorithm ||= algorithm_parts.first + def signing_algorithm = @signing_algorithm ||= algorithm_parts.second + alias :encoding_algorithm :encryption_algorithm - def encoded? - !encrypted? - end - - def ttl? - ttl.present? - end + def encrypted? = encryption_algorithm.in?(ENCRYPTION_ALGORITHMS) + def encoded? = encoding_algorithm.in?(ENCODING_ALGORITHMS) + def ed25519? = signing_algorithm == 'ed25519' + def rsa? = signing_algorithm.in?(%w[rsa-pss-sha256 rsa-sha256]) + def ttl? = ttl.present? def encrypt(value, secret:) - aes = OpenSSL::Cipher.new(ENCRYPT_ALGORITHM) + aes = OpenSSL::Cipher.new(encryption_algorithm) aes.encrypt key = OpenSSL::Digest::SHA256.digest(secret) @@ -105,24 +105,21 @@ def encode(value, strict: false) enc.chomp end - def sign(value, key:, algorithm:, prefix:) - raise InvalidAlgorithmError, 'algorithm is invalid' unless - ALLOWED_ALGORITHMS.include?(algorithm) - + def sign(value, prefix:) data = "#{prefix}/#{value}" - case algorithm + case signing_algorithm when 'rsa-pss-sha256' - rsa = OpenSSL::PKey::RSA.new(key) + rsa = OpenSSL::PKey::RSA.new(private_key) sig = rsa.sign_pss(OpenSSL::Digest::SHA256.new, data, salt_length: :max, mgf1_hash: 'SHA256') when 'rsa-sha256' - rsa = OpenSSL::PKey::RSA.new(key) + rsa = OpenSSL::PKey::RSA.new(private_key) sig = rsa.sign(OpenSSL::Digest::SHA256.new, data) when 'ed25519' - ed25519 = Ed25519::SigningKey.new([key].pack('H*')) + ed25519 = Ed25519::SigningKey.new([private_key].pack('H*')) sig = ed25519.sign(data) else - raise InvalidAlgorithmError, 'signing scheme is not supported' + raise InvalidAlgorithmError, 'signing algorithm is not supported' end encode(sig, strict: true) diff --git a/app/services/license_checkout_service.rb b/app/services/license_checkout_service.rb index 73bb13ea37..451a02ae1b 100644 --- a/app/services/license_checkout_service.rb +++ b/app/services/license_checkout_service.rb @@ -24,7 +24,23 @@ def initialize(license:, environment: nil, include: [], **kwargs) @license = license @environment = environment - super(scheme: license.scheme, include:, **kwargs) + # this is used when an algorithm is not explicitly provided + sign = case license.scheme + when 'RSA_2048_PKCS1_PSS_SIGN_V2', + 'RSA_2048_PKCS1_PSS_SIGN' + 'rsa-pss-sha256' + when 'RSA_2048_PKCS1_SIGN_V2', + 'RSA_2048_PKCS1_SIGN', + 'RSA_2048_PKCS1_ENCRYPT', + 'RSA_2048_JWT_RS256' + 'rsa-sha256' + when 'ED25519_SIGN' + 'ed25519' + else + true + end + + super(sign:, include:, **kwargs) end def call @@ -45,12 +61,8 @@ def call else encode(data, strict: true) end - sig = sign(enc, key: private_key, algorithm: algorithm, prefix: 'license') - alg = if encrypted? - "#{ENCRYPT_ALGORITHM}+#{algorithm}" - else - "#{ENCODE_ALGORITHM}+#{algorithm}" - end + sig = sign(enc, prefix: 'license') + alg = algorithm doc = { enc: enc, sig: sig, alg: alg } enc = encode(doc.to_json) @@ -69,6 +81,7 @@ def call expires_at: expires_at, ttl: ttl, includes: incl, + algorithm:, ) end diff --git a/app/services/machine_checkout_service.rb b/app/services/machine_checkout_service.rb index e9e851f3d7..285da12455 100644 --- a/app/services/machine_checkout_service.rb +++ b/app/services/machine_checkout_service.rb @@ -32,7 +32,23 @@ def initialize(machine:, environment: nil, include: [], **kwargs) @license = machine.license @environment = environment - super(scheme: machine.license.scheme, include:, **kwargs) + # this is used when an algorithm is not explicitly provided + sign = case license.scheme + when 'RSA_2048_PKCS1_PSS_SIGN_V2', + 'RSA_2048_PKCS1_PSS_SIGN' + 'rsa-pss-sha256' + when 'RSA_2048_PKCS1_SIGN_V2', + 'RSA_2048_PKCS1_SIGN', + 'RSA_2048_PKCS1_ENCRYPT', + 'RSA_2048_JWT_RS256' + 'rsa-sha256' + when 'ED25519_SIGN' + 'ed25519' + else + true + end + + super(sign:, include:, **kwargs) end def call @@ -53,13 +69,8 @@ def call else encode(data, strict: true) end - sig = sign(enc, key: private_key, algorithm: algorithm, prefix: 'machine') - - alg = if encrypted? - "#{ENCRYPT_ALGORITHM}+#{algorithm}" - else - "#{ENCODE_ALGORITHM}+#{algorithm}" - end + sig = sign(enc, prefix: 'machine') + alg = algorithm doc = { enc: enc, sig: sig, alg: alg } enc = encode(doc.to_json) @@ -79,6 +90,7 @@ def call expires_at: expires_at, ttl: ttl, includes: incl, + algorithm:, ) end @@ -87,33 +99,4 @@ def call attr_reader :environment, :machine, :license - - def private_key - case license.scheme - when 'RSA_2048_PKCS1_PSS_SIGN_V2', - 'RSA_2048_PKCS1_SIGN_V2', - 'RSA_2048_PKCS1_PSS_SIGN', - 'RSA_2048_PKCS1_SIGN', - 'RSA_2048_PKCS1_ENCRYPT', - 'RSA_2048_JWT_RS256' - account.private_key - else - account.ed25519_private_key - end - end - - def algorithm - case license.scheme - when 'RSA_2048_PKCS1_PSS_SIGN_V2', - 'RSA_2048_PKCS1_PSS_SIGN' - 'rsa-pss-sha256' - when 'RSA_2048_PKCS1_SIGN_V2', - 'RSA_2048_PKCS1_SIGN', - 'RSA_2048_PKCS1_ENCRYPT', - 'RSA_2048_JWT_RS256' - 'rsa-sha256' - else - 'ed25519' - end - end end diff --git a/features/api/v1/licenses/actions/checkouts.feature b/features/api/v1/licenses/actions/checkouts.feature index 1dc5d4bde7..100ead2e61 100644 --- a/features/api/v1/licenses/actions/checkouts.feature +++ b/features/api/v1/licenses/actions/checkouts.feature @@ -368,6 +368,160 @@ Feature: License checkout actions And sidekiq should have 1 "metric" job And sidekiq should have 1 "request-log" job + Scenario: Admin performs a license checkout with a custom algorithm (POST) + Given time is frozen at "2022-10-16T14:52:48.000Z" + And the current account is "test1" + And the current account has 1 "webhook-endpoint" + And the current account has 1 "license" + And I am an admin of account "test1" + And I use an authentication token + When I send a POST request to "/accounts/test1/licenses/$0/actions/check-out" with the following: + """ + { "meta": { "algorithm": "aes-256-gcm+rsa-pss-sha256" } } + """ + Then the response status should be "200" + And the response body should be a "license-file" with a certificate signed using "rsa-pss-sha256" + And the response body should be a "license-file" with the following encrypted certificate data: + """ + { + "data": { + "type": "licenses", + "id": "$licenses[0]" + } + } + """ + And sidekiq should have 1 "webhook" job + And sidekiq should have 1 "metric" job + And sidekiq should have 1 "request-log" job + And time is unfrozen + + Scenario: Admin performs a license checkout with a custom algorithm (GET) + Given time is frozen at "2022-10-16T14:52:48.000Z" + And the current account is "test1" + And the current account has 1 "webhook-endpoint" + And the current account has 1 "policy" with the following: + """ + { "scheme": "ED25519_SIGN" } + """ + And the current account has 1 "license" for the last "policy" + And I am an admin of account "test1" + And I use an authentication token + When I send a GET request to "/accounts/test1/licenses/$0/actions/check-out?algorithm=base64%2Brsa-sha256" + Then the response status should be "200" + And the response should be a "LICENSE" certificate signed using "rsa-sha256" + And the response should be a "LICENSE" certificate with the following encoded data: + """ + { + "data": { + "type": "licenses", + "id": "$licenses[0]" + } + } + """ + And sidekiq should have 1 "webhook" job + And sidekiq should have 1 "metric" job + And sidekiq should have 1 "request-log" job + And time is unfrozen + + Scenario: Admin performs a license checkout with an invalid encoding algorithm (POST) + Given the current account is "test1" + And the current account has 1 "webhook-endpoint" + And the current account has 1 "license" + And I am an admin of account "test1" + And I use an authentication token + When I send a POST request to "/accounts/test1/licenses/$0/actions/check-out" with the following: + """ + { "meta": { "algorithm": "foo+ed25519" } } + """ + Then the response status should be "400" + And the first error should have the following properties: + """ + { + "title": "Bad request", + "detail": "invalid encoding algorithm", + "code": "CHECKOUT_ALGORITHM_INVALID", + "source": { + "parameter": "algorithm" + } + } + """ + And sidekiq should have 0 "webhook" jobs + And sidekiq should have 0 "metric" jobs + And sidekiq should have 1 "request-log" job + + Scenario: Admin performs a license checkout with an invalid signing algorithm (POST) + Given the current account is "test1" + And the current account has 1 "webhook-endpoint" + And the current account has 1 "license" + And I am an admin of account "test1" + And I use an authentication token + When I send a POST request to "/accounts/test1/licenses/$0/actions/check-out" with the following: + """ + { "meta": { "algorithm": "base64+foo" } } + """ + Then the response status should be "400" + And the first error should have the following properties: + """ + { + "title": "Bad request", + "detail": "invalid signing algorithm", + "code": "CHECKOUT_ALGORITHM_INVALID", + "source": { + "parameter": "algorithm" + } + } + """ + And sidekiq should have 0 "webhook" jobs + And sidekiq should have 0 "metric" jobs + And sidekiq should have 1 "request-log" job + + Scenario: Admin performs a license checkout with a nil algorithm (POST) + Given the current account is "test1" + And the current account has 1 "webhook-endpoint" + And the current account has 1 "license" + And I am an admin of account "test1" + And I use an authentication token + When I send a POST request to "/accounts/test1/licenses/$0/actions/check-out" with the following: + """ + { "meta": { "algorithm": null } } + """ + Then the response status should be "200" + And the response body should be a "license-file" with a certificate signed using "ed25519" + And the response body should be a "license-file" with the following encoded certificate data: + """ + { + "data": { + "type": "licenses", + "id": "$licenses[0]" + } + } + """ + And sidekiq should have 1 "webhook" job + And sidekiq should have 1 "metric" job + And sidekiq should have 1 "request-log" job + + Scenario: Admin performs a license checkout with an empty algorithm (GET) + Given the current account is "test1" + And the current account has 1 "webhook-endpoint" + And the current account has 1 "license" + And I am an admin of account "test1" + And I use an authentication token + When I send a GET request to "/accounts/test1/licenses/$0/actions/check-out?algorithm=" + Then the response status should be "200" + And the response should be a "LICENSE" certificate signed using "ed25519" + And the response should be a "LICENSE" certificate with the following encoded data: + """ + { + "data": { + "type": "licenses", + "id": "$licenses[0]" + } + } + """ + And sidekiq should have 1 "webhook" job + And sidekiq should have 1 "metric" job + And sidekiq should have 1 "request-log" job + Scenario: Admin performs a license checkout with a custom TTL (POST) Given time is frozen at "2022-10-16T14:52:48.000Z" And the current account is "test1" diff --git a/features/api/v1/machines/actions/checkouts.feature b/features/api/v1/machines/actions/checkouts.feature index 1038510a7c..0e54d9d0d7 100644 --- a/features/api/v1/machines/actions/checkouts.feature +++ b/features/api/v1/machines/actions/checkouts.feature @@ -374,6 +374,161 @@ Feature: Machine checkout actions And sidekiq should have 1 "metric" job And sidekiq should have 1 "request-log" job + Scenario: Admin performs a machine checkout with a custom algorithm (POST) + Given time is frozen at "2022-10-16T14:52:48.000Z" + And the current account is "test1" + And the current account has 1 "webhook-endpoint" + And the current account has 1 "machine" + And I am an admin of account "test1" + And I use an authentication token + When I send a POST request to "/accounts/test1/machines/$0/actions/check-out" with the following: + """ + { "meta": { "algorithm": "aes-256-gcm+rsa-pss-sha256" } } + """ + Then the response status should be "200" + And the response body should be a "machine-file" with a certificate signed using "rsa-pss-sha256" + And the response body should be a "machine-file" with the following encrypted certificate data: + """ + { + "data": { + "type": "machines", + "id": "$machines[0]" + } + } + """ + And sidekiq should have 1 "webhook" job + And sidekiq should have 1 "metric" job + And sidekiq should have 1 "request-log" job + And time is unfrozen + + Scenario: Admin performs a machine checkout with a custom algorithm (GET) + Given time is frozen at "2022-10-16T14:52:48.000Z" + And the current account is "test1" + And the current account has 1 "webhook-endpoint" + And the current account has 1 "policy" with the following: + """ + { "scheme": "ED25519_SIGN" } + """ + And the current account has 1 "license" for the last "policy" + And the current account has 1 "machine" for the last "license" + And I am an admin of account "test1" + And I use an authentication token + When I send a GET request to "/accounts/test1/machines/$0/actions/check-out?algorithm=base64%2Brsa-sha256" + Then the response status should be "200" + And the response should be a "MACHINE" certificate signed using "rsa-sha256" + And the response should be a "MACHINE" certificate with the following encoded data: + """ + { + "data": { + "type": "machines", + "id": "$machines[0]" + } + } + """ + And sidekiq should have 1 "webhook" job + And sidekiq should have 1 "metric" job + And sidekiq should have 1 "request-log" job + And time is unfrozen + + Scenario: Admin performs a machine checkout with an invalid encoding algorithm (POST) + Given the current account is "test1" + And the current account has 1 "webhook-endpoint" + And the current account has 1 "machine" + And I am an admin of account "test1" + And I use an authentication token + When I send a POST request to "/accounts/test1/machines/$0/actions/check-out" with the following: + """ + { "meta": { "algorithm": "foo+ed25519" } } + """ + Then the response status should be "400" + And the first error should have the following properties: + """ + { + "title": "Bad request", + "detail": "invalid encoding algorithm", + "code": "CHECKOUT_ALGORITHM_INVALID", + "source": { + "parameter": "algorithm" + } + } + """ + And sidekiq should have 0 "webhook" jobs + And sidekiq should have 0 "metric" jobs + And sidekiq should have 1 "request-log" job + + Scenario: Admin performs a machines checkout with an invalid signing algorithm (POST) + Given the current account is "test1" + And the current account has 1 "webhook-endpoint" + And the current account has 1 "machine" + And I am an admin of account "test1" + And I use an authentication token + When I send a POST request to "/accounts/test1/machines/$0/actions/check-out" with the following: + """ + { "meta": { "algorithm": "base64+foo" } } + """ + Then the response status should be "400" + And the first error should have the following properties: + """ + { + "title": "Bad request", + "detail": "invalid signing algorithm", + "code": "CHECKOUT_ALGORITHM_INVALID", + "source": { + "parameter": "algorithm" + } + } + """ + And sidekiq should have 0 "webhook" jobs + And sidekiq should have 0 "metric" jobs + And sidekiq should have 1 "request-log" job + + Scenario: Admin performs a machine checkout with a nil algorithm (POST) + Given the current account is "test1" + And the current account has 1 "webhook-endpoint" + And the current account has 1 "machine" + And I am an admin of account "test1" + And I use an authentication token + When I send a POST request to "/accounts/test1/machines/$0/actions/check-out" with the following: + """ + { "meta": { "algorithm": null } } + """ + Then the response status should be "200" + And the response body should be a "machine-file" with a certificate signed using "ed25519" + And the response body should be a "machine-file" with the following encoded certificate data: + """ + { + "data": { + "type": "machines", + "id": "$machines[0]" + } + } + """ + And sidekiq should have 1 "webhook" job + And sidekiq should have 1 "metric" job + And sidekiq should have 1 "request-log" job + + Scenario: Admin performs a machine checkout with an empty algorithm (GET) + Given the current account is "test1" + And the current account has 1 "webhook-endpoint" + And the current account has 1 "machine" + And I am an admin of account "test1" + And I use an authentication token + When I send a GET request to "/accounts/test1/machines/$0/actions/check-out?algorithm=" + Then the response status should be "200" + And the response should be a "MACHINE" certificate signed using "ed25519" + And the response should be a "MACHINE" certificate with the following encoded data: + """ + { + "data": { + "type": "machines", + "id": "$machines[0]" + } + } + """ + And sidekiq should have 1 "webhook" job + And sidekiq should have 1 "metric" job + And sidekiq should have 1 "request-log" job + Scenario: Admin performs a machine checkout with a custom TTL (POST) Given time is frozen at "2022-10-16T14:52:48.000Z" And the current account is "test1" diff --git a/features/step_definitions/request_steps.rb b/features/step_definitions/request_steps.rb index 8f597b68db..2859c15cce 100644 --- a/features/step_definitions/request_steps.rb +++ b/features/step_definitions/request_steps.rb @@ -1126,6 +1126,10 @@ res = last_response json = JSON.parse(last_response.body) cert = json.dig('data', 'attributes', 'certificate') + alg = json.dig('data', 'attributes', 'algorithm') + + expect(alg).to start_with 'base64+' + type = json.dig('data', 'type') expect(type).to eq resource_type.pluralize @@ -1175,7 +1179,11 @@ res = last_response json = JSON.parse(last_response.body) cert = json.dig('data', 'attributes', 'certificate') - type = json.dig('data', 'type') + alg = json.dig('data', 'attributes', 'algorithm') + + expect(alg).to start_with 'aes-256-gcm+' + + type = json.dig('data', 'type') expect(type).to eq resource_type.pluralize diff --git a/spec/services/license_checkout_service_spec.rb b/spec/services/license_checkout_service_spec.rb index 29292cfa06..93388068ba 100644 --- a/spec/services/license_checkout_service_spec.rb +++ b/spec/services/license_checkout_service_spec.rb @@ -126,6 +126,50 @@ expect { checkout.call }.to raise_error LicenseCheckoutService::InvalidTTLError end + + it 'should raise an error when algorithm is invalid' do + checkout = -> { + LicenseCheckoutService.call( + algorithm: 'foo+bar', + account:, + license:, + ) + } + + expect { checkout.call }.to raise_error LicenseCheckoutService::InvalidAlgorithmError + end + end + + %w[ + aes-256-gcm+ed25519 + aes-256-gcm+rsa-pss-sha256 + aes-256-gcm+rsa-sha256 + base64+ed25519 + base64+rsa-pss-sha256 + base64+rsa-sha256 + ].each do |algorithm| + context "when the algorithm is #{algorithm}" do + let(:license) { create(:license, account:) } + + it 'should have a correct algorithm' do + license_file = LicenseCheckoutService.call( + algorithm:, + account:, + license:, + ) + + cert = license_file.certificate + payload = cert.delete_prefix("-----BEGIN LICENSE FILE-----\n") + .delete_suffix("-----END LICENSE FILE-----\n") + + dec = Base64.decode64(payload) + json = JSON.parse(dec) + + expect(json).to include( + 'alg' => algorithm, + ) + end + end end %w[ @@ -543,11 +587,77 @@ end end + context 'when not using encryption' do + it 'should return an encoded JSON payload' do + license_file = LicenseCheckoutService.call( + encrypt: false, + account:, + license:, + ) + + cert = license_file.certificate + dec = nil + enc = cert.delete_prefix("-----BEGIN LICENSE FILE-----\n") + .delete_suffix("-----END LICENSE FILE-----\n") + + expect { dec = Base64.decode64(enc) }.to_not raise_error + expect(dec).to_not be_nil + + json = nil + + expect { json = JSON.parse(dec) }.to_not raise_error + expect(json).to_not be_nil + expect(json).to include( + 'enc' => a_kind_of(String), + 'sig' => a_kind_of(String), + 'alg' => a_kind_of(String), + ) + end + + it 'should return an unencrypted license' do + license_file = LicenseCheckoutService.call( + encrypt: false, + account:, + license:, + ) + + cert = license_file.certificate + payload = cert.delete_prefix("-----BEGIN LICENSE FILE-----\n") + .delete_suffix("-----END LICENSE FILE-----\n") + + json = JSON.parse(Base64.decode64(payload)) + enc = json.fetch('enc') + decode = -> { + dec = Base64.decode64(enc) + + JSON.parse(dec) + } + + expect { decode.call }.to_not raise_error + + data = decode.call + + expect(data).to_not be_nil + expect(data).to include( + 'meta' => include( + 'issued' => license_file.issued_at.iso8601(3), + 'expiry' => license_file.expires_at.iso8601(3), + 'ttl' => license_file.ttl, + ), + 'data' => include( + 'type' => 'licenses', + 'id' => license.id, + ), + ) + end + end + context 'when using encryption' do it 'should return an encoded JSON payload' do license_file = LicenseCheckoutService.call( account: account, license: license, + encrypt: true, ) cert = license_file.certificate diff --git a/spec/services/machine_checkout_service_spec.rb b/spec/services/machine_checkout_service_spec.rb index 6985109037..4c5e71867e 100644 --- a/spec/services/machine_checkout_service_spec.rb +++ b/spec/services/machine_checkout_service_spec.rb @@ -128,6 +128,50 @@ expect { checkout.call }.to raise_error MachineCheckoutService::InvalidTTLError end + + it 'should raise an error when algorithm is invalid' do + checkout = -> { + MachineCheckoutService.call( + algorithm: 'foo+bar', + account:, + machine:, + ) + } + + expect { checkout.call }.to raise_error MachineCheckoutService::InvalidAlgorithmError + end + end + + %w[ + aes-256-gcm+ed25519 + aes-256-gcm+rsa-pss-sha256 + aes-256-gcm+rsa-sha256 + base64+ed25519 + base64+rsa-pss-sha256 + base64+rsa-sha256 + ].each do |algorithm| + context "when the algorithm is #{algorithm}" do + let(:license) { create(:license, account:) } + + it 'should have a correct algorithm' do + machine_file = MachineCheckoutService.call( + algorithm:, + account:, + machine:, + ) + + cert = machine_file.certificate + payload = cert.delete_prefix("-----BEGIN MACHINE FILE-----\n") + .delete_suffix("-----END MACHINE FILE-----\n") + + dec = Base64.decode64(payload) + json = JSON.parse(dec) + + expect(json).to include( + 'alg' => algorithm, + ) + end + end end %w[ @@ -545,11 +589,77 @@ end end + context 'when not using encryption' do + it 'should return an encoded JSON payload' do + machine_file = MachineCheckoutService.call( + encrypt: false, + account:, + machine:, + ) + + cert = machine_file.certificate + dec = nil + enc = cert.delete_prefix("-----BEGIN MACHINE FILE-----\n") + .delete_suffix("-----END MACHINE FILE-----\n") + + expect { dec = Base64.decode64(enc) }.to_not raise_error + expect(dec).to_not be_nil + + json = nil + + expect { json = JSON.parse(dec) }.to_not raise_error + expect(json).to_not be_nil + expect(json).to include( + 'enc' => a_kind_of(String), + 'sig' => a_kind_of(String), + 'alg' => a_kind_of(String), + ) + end + + it 'should return an unencrypted machine' do + machine_file = MachineCheckoutService.call( + encrypt: false, + account:, + machine:, + ) + + cert = machine_file.certificate + payload = cert.delete_prefix("-----BEGIN MACHINE FILE-----\n") + .delete_suffix("-----END MACHINE FILE-----\n") + + json = JSON.parse(Base64.decode64(payload)) + enc = json.fetch('enc') + decode = -> { + dec = Base64.decode64(enc) + + JSON.parse(dec) + } + + expect { decode.call }.to_not raise_error + + data = decode.call + + expect(data).to_not be_nil + expect(data).to include( + 'meta' => include( + 'issued' => machine_file.issued_at.iso8601(3), + 'expiry' => machine_file.expires_at.iso8601(3), + 'ttl' => machine_file.ttl, + ), + 'data' => include( + 'type' => 'machines', + 'id' => machine.id, + ), + ) + end + end + context 'when using encryption' do it 'should return an encoded JSON payload' do machine_file = MachineCheckoutService.call( account: account, machine: machine, + encrypt: true, ) cert = machine_file.certificate