diff --git a/.dockerignore b/.dockerignore index 0ece2222fb..7244debd3f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,11 +17,14 @@ coverage demo dev docker +gems/slosilo/Gemfile.lock +gems/slosilo/spec/reports log package run spec/reports spec/reports-audit + tmp # Ignore directories that are only relevant in gh diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7219cbfa0d..54cef097e6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,10 +1 @@ -* @cyberark/conjur-core-team @conjurinc/conjur-core-team @conjurdemos/conjur-core-team - -# Changes to .trivyignore require Security Architect approval -.trivyignore @cyberark/security-architects @conjurinc/security-architects @conjurdemos/security-architects - -# Changes to .codeclimate.yml require Quality Architect approval -.codeclimate.yml @cyberark/quality-architects @conjurinc/quality-architects @conjurdemos/quality-architects - -# Changes to SECURITY.md require Security Architect approval -SECURITY.md @cyberark/security-architects @conjurinc/security-architects @conjurdemos/security-architects +* @jeniaSakirko @cyberark/ConjurCloud diff --git a/.gitignore b/.gitignore index ede6e0d417..0708e2dc07 100644 --- a/.gitignore +++ b/.gitignore @@ -73,4 +73,24 @@ conjur_git_commit # AuthnOIDC V2 w/ Identity setup dev/policies/authenticators/authn-oidc/identity-users.yml +gem/slosilo/*.gem +gem/slosilo/*.rbc +gem/slosilo/.bundle +gem/slosilo/.yardoc +gem/slosilo/InstalledFiles +gem/slosilo/_yardoc +gem/slosilo/coverage +gem/slosilo/doc/ +gem/slosilo/lib/bundler/man +gem/slosilo/pkg +gem/slosilo/rdoc +gem/slosilo/spec/reports +gem/slosilo/test/tmp +gem/slosilo/test/version_tmp +gem/slosilo/tmp +gem/slosilo/.rvmrc +gem/slosilo/.project +gem/slosilo/.kateproject.d +gem/slosilo/.idea + VERSION diff --git a/CHANGELOG.md b/CHANGELOG.md index 23a48a6f01..f5f3c698a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,76 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Nothing should go in this section, please add to the latest unreleased version (and update the corresponding date), or add a new version. +## [1.0.1-cloud] - 2023-06-21 +### Changed +- Improve DB connection usage https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-34591 +- Pull Slosilo library to Conjur +- Change Slosilo id from "authn:account:host/user" to "authn:account:host/user:current" +- Add update slosilo key option to slosilo put key function + +## [1.0.0-cloud] - 2023-06-07 +### Changed +- Improve DB queries for Edge https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-37081 +- Change Slosilo id regex to support: authn:conjur:user/host in addition to authn:conjur +- Split Slosilo key for hosts and users +- Fix No continuation in replication when an error occurs https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-35741 + +## [0.0.11-cloud] - 2023-05-24 +### Changed +- Remove edge-hosts for edge endpoint +- oidc user name to be compare as lowercase https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-37450 +- Support versions field in all secrets endpoint https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-37056 + +## [0.0.10-cloud] - 2023-05-16 +### Added +- Implementation health endpoint + https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-37338 + +## [0.0.9-cloud] - 2023-05-09 +### Added +- Add an option to get all secrets from edge api with encode bse64, by Accept-Encoding header + https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-35742 + +## [0.0.8-cloud] - 2023-04-30 +### Added +- New edge-hosts endpoints for edge +- Api change. Host API key is return as hashed + https://ca-il-jira.il.cyber-ark.com:8443/browse/ONYX-35892 + +## [0.0.7-cloud] - 2023-03-27 +### Changed +- Merge from master 2023-03-27 to 2023-03-26 + +## [0.0.6-cloud] - 2023-03-19 +### Security +- Updated github-pages version in docs/Gemfile to allow upgrading activesupport + to v7.0.4.2 to resolve CVE-2022-22796 + [cyberark/conjur#2729](https://github.com/cyberark/conjur/pull/2729) +- Upgraded rack to v2.2.6.3 to resolve CVE-2023-27530 + [cyberark/conjur#2739](https://github.com/cyberark/conjur/pull/2739) +- Upgraded rack to v2.2.6.4 to resolve CVE-2023-27539 + [cyberark/conjur#2750](https://github.com/cyberark/conjur/pull/2750) + +## [0.0.5-cloud] - 2023-03-15 +### Changed +- Add get SlosiloKey api + +## [0.0.4-cloud] - 2023-03-12 +### Changed +- Change count=true not to consider limit and sum all + +## [0.0.3-cloud] - 2023-03-06 +### Changed +- Change edge group name + +## [0.0.2-cloud] - 2023-03-06 +### Added +- Edge host endpoint and secret endpoint + +## [0.0.1-cloud] - 2022-01-13 +### Changed +- Remove auto-release options to allow for a pseudo-fork development on a branch + ## [1.19.5] - 2023-05-16 ### Security @@ -64,7 +134,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. [cyberark/conjur#2739](https://github.com/cyberark/conjur/pull/2739) - Upgraded rack to v2.2.6.4 to resolve CVE-2023-27539 [cyberark/conjur#2750](https://github.com/cyberark/conjur/pull/2750) -- Updated nokogiri to 1.14.3 for CVE-2023-29469 and CVE-2023-28484 and rails to +- Updated nokogiri to 1.14.3 for CVE-2023-29469 and CVE-2023-28484 and rails to 6.1.7.3 for CVE-2023-28120 in Gemfile.lock, nokogiri to 1.1.4.3 for CVE-2023-29469 and commonmarker to 0.23.9 for CVE-2023-24824 and CVE-2023-26485 in docs/Gemfile.lock (all Medium severity issues flagged by Dependabot) diff --git a/Dockerfile b/Dockerfile index 96a88bfd99..9475f93aad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM cyberark/ubuntu-ruby-fips:latest +FROM cyberark/ubuntu-ruby-fips:2.0.7-618 ENV DEBIAN_FRONTEND=noninteractive \ PORT=80 \ diff --git a/Dockerfile.test b/Dockerfile.test index a91f36550c..1244e85fa5 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -1,4 +1,4 @@ ARG VERSION=latest -FROM conjur:${VERSION} +FROM conjur-cloud:${VERSION} RUN bundle --no-deployment --without '' diff --git a/Gemfile b/Gemfile index 330c68f777..2b3e3c638e 100644 --- a/Gemfile +++ b/Gemfile @@ -36,7 +36,7 @@ gem 'bcrypt' gem 'gli', require: false gem 'listen' gem 'rexml', '~> 3.2' -gem 'slosilo', '~> 3.0' +gem 'slosilo', path: 'gems/slosilo' # Explicitly required as there are vulnerabilities in older versions gem "ffi", ">= 1.9.24" @@ -99,6 +99,7 @@ group :development, :test do gem 'rspec-core' gem 'rspec-rails' gem 'ruby-debug-ide' + gem 'pact' # We use a post-coverage hook to sleep covered processes until we're ready to # collect the coverage reports in CI. Because of this, we don't want bundler diff --git a/Gemfile.lock b/Gemfile.lock index 4eab22f583..24fce0770c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -13,6 +13,11 @@ PATH activesupport (>= 4.2) safe_yaml +PATH + remote: gems/slosilo + specs: + slosilo (3.0.1) + GEM remote: https://rubygems.org/ specs: @@ -89,6 +94,7 @@ GEM thor (~> 1.0) ast (2.4.2) attr_required (1.0.1) + awesome_print (1.9.2) aws-eventstream (1.2.0) aws-partitions (1.553.0) aws-sdk-core (3.126.0) @@ -211,6 +217,8 @@ GEM event_emitter (0.2.6) eventmachine (1.2.7) excon (0.91.0) + expgen (0.1.1) + parslet faye-websocket (0.11.1) eventmachine (>= 0.12.0) websocket-driver (>= 0.5.1) @@ -218,6 +226,8 @@ GEM ffi-compiler (1.0.1) ffi (>= 1.0.0) rake + filelock (1.1.1) + find_a_port (1.0.1) gli (2.21.0) globalid (1.1.0) activesupport (>= 5.0) @@ -245,6 +255,7 @@ GEM activesupport (>= 4.2.0) multi_json (>= 1.2) jmespath (1.6.1) + json (2.6.3) json-jwt (1.13.0) activesupport (>= 4.2) aes_key_wrap @@ -308,9 +319,32 @@ GEM validate_email validate_url webfinger (>= 1.0.1) + pact (1.63.0) + pact-mock_service (~> 3.0, >= 3.3.1) + pact-support (~> 1.16, >= 1.16.9) + rack-test (>= 0.6.3, < 3.0.0) + rspec (~> 3.0) + term-ansicolor (~> 1.0) + thor (>= 0.20, < 2.0) + webrick (~> 1.3) + pact-mock_service (3.11.1) + filelock (~> 1.1) + find_a_port (~> 1.0.1) + json + pact-support (~> 1.16, >= 1.16.4) + rack (~> 2.0) + rspec (>= 2.14) + thor (>= 0.19, < 2.0) + webrick (~> 1.3) + pact-support (1.17.0) + awesome_print (~> 1.9) + diff-lcs (~> 1.4) + expgen (~> 0.1) + term-ansicolor (~> 1.0) parallel (1.21.0) parser (3.0.3.2) ast (~> 2.4.1) + parslet (2.0.0) pg (1.2.3) powerpack (0.1.3) pry (0.13.1) @@ -440,7 +474,6 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.12.3) simplecov_json_formatter (0.1.4) - slosilo (3.0.1) spring (2.1.0) spring-commands-cucumber (1.0.1) spring (>= 0.9.1) @@ -457,11 +490,16 @@ GEM activesupport (>= 3) attr_required (>= 0.0.5) httpclient (>= 2.4) + sync (0.5.0) sys-uname (1.2.2) ffi (~> 1.1) table_print (1.5.7) + term-ansicolor (1.7.1) + tins (~> 1.0) thor (1.2.1) timeout (0.3.2) + tins (1.32.1) + sync tzinfo (2.0.6) concurrent-ruby (~> 1.0) unf (0.1.4) @@ -535,6 +573,7 @@ DEPENDENCIES net-ssh nokogiri (>= 1.8.2) openid_connect + pact parallel pg pry-byebug @@ -561,7 +600,7 @@ DEPENDENCIES sequel-postgres-schemata sequel-rails simplecov - slosilo (~> 3.0) + slosilo! spring spring-commands-cucumber spring-commands-rspec diff --git a/Jenkinsfile b/Jenkinsfile index cef3cd441e..2eddee6ebb 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -46,37 +46,6 @@ These are defined in runConjurTests, and also include the one-offs gcp_authenticator */ -// Automated release, promotion and dependencies -properties([ - // Include the automated release parameters for the build - release.addParams(), - // Dependencies of the project that should trigger builds - dependencies(['cyberark/conjur-base-image', - 'cyberark/conjur-api-ruby', - 'conjurinc/debify']) -]) - -// Performs release promotion. No other stages will be run -if (params.MODE == "PROMOTE") { - release.promote(params.VERSION_TO_PROMOTE) { sourceVersion, targetVersion, assetDirectory -> - sh "docker pull registry.tld/cyberark/conjur:${sourceVersion}" - sh "docker tag registry.tld/cyberark/conjur:${sourceVersion} conjur:${sourceVersion}" - sh "docker pull registry.tld/conjur-ubi:${sourceVersion}" - sh "docker tag registry.tld/conjur-ubi:${sourceVersion} conjur-ubi:${sourceVersion}" - sh "summon -f ./secrets.yml ./publish-images.sh --promote --redhat --base-version=${sourceVersion} --version=${targetVersion}" - - // Trigger Conjurops build to push newly promoted releases of conjur to ConjurOps Staging - build( - job:'../conjurinc--conjurops/master', - parameters:[ - string(name: 'conjur_oss_source_image', value: "cyberark/conjur:${targetVersion}") - ], - wait: false - ) - } - return -} - pipeline { agent { label 'executor-v2' } @@ -118,26 +87,7 @@ pipeline { } - environment { - // Sets the MODE to the specified or autocalculated value as appropriate - MODE = release.canonicalizeMode() - } - stages { - // Aborts any builds triggered by another project that wouldn't include any changes - stage ("Skip build if triggering job didn't create a release") { - when { - expression { - MODE == "SKIP" - } - } - steps { - script { - currentBuild.result = 'ABORTED' - error("Aborting build because this build was triggered from upstream, but no release was built") - } - } - } // Generates a VERSION file based on the current build number and latest version in CHANGELOG.md stage('Validate Changelog and set version') { steps { @@ -146,27 +96,6 @@ pipeline { } } - stage('Fetch tags') { - steps { - withCredentials( - [ - usernameColonPassword( - credentialsId: 'conjur-jenkins-api', variable: 'GITCREDS' - ) - ] - ) { - sh ''' - git fetch --tags "$( - git remote get-url origin | - sed -e "s|https://|https://$GITCREDS@|" - )" - # print them out to make sure, can remove when this is robust - git tag - ''' - } - } - } - stage('Validate Changelog') { when { expression { params.RUN_ONLY == '' } @@ -228,38 +157,22 @@ pipeline { parallel { stage("Scan Docker Image for fixable issues") { steps { - scanAndReport("conjur:${tagWithSHA()}", "HIGH", false) + scanAndReport("conjur-cloud:${tagWithSHA()}", "HIGH", false) } } stage("Scan Docker image for total issues") { steps { - scanAndReport("conjur:${tagWithSHA()}", "NONE", true) + scanAndReport("conjur-cloud:${tagWithSHA()}", "NONE", true) } } stage("Scan UBI-based Docker Image for fixable issues") { steps { - scanAndReport("conjur-ubi:${tagWithSHA()}", "HIGH", false) + scanAndReport("conjur-ubi-cloud:${tagWithSHA()}", "HIGH", false) } } stage("Scan UBI-based Docker image for total issues") { steps { - scanAndReport("conjur-ubi:${tagWithSHA()}", "NONE", true) - } - } - } - } - - // TODO: Add comments explaining which env vars are set here. - stage('Prepare For CodeClimate Coverage Report Submission') { - when { - expression { params.RUN_ONLY == '' } - } - steps { - catchError(buildResult: 'SUCCESS', stageResult: 'FAILURE') { - script { - ccCoverage.dockerPrep() - sh 'mkdir -p coverage' - env.CODE_CLIMATE_PREPARED = "true" + scanAndReport("conjur-ubi-cloud:${tagWithSHA()}", "NONE", true) } } } @@ -310,6 +223,7 @@ pipeline { spec/reports/*.xml, spec/reports-audit/*.xml, gems/conjur-rack/spec/reports/*.xml, + gems/slosilo/spec/reports/*.xml, cucumber/*/features/reports/**/*.xml ''' ) @@ -612,17 +526,6 @@ pipeline { } post { - success { - script { - if (env.BRANCH_NAME == 'master') { - build( - job:'../cyberark--secrets-provider-for-k8s/main', - wait: false - ) - } - } - } - always { script { @@ -636,9 +539,9 @@ pipeline { archiveFiles('coverage/.resultset*.json') archiveFiles('coverage/coverage.json') archiveFiles('coverage/codeclimate.json') - archiveFiles( - 'ci/test_suites/authenticators_k8s/output/simplecov-resultset-authnk8s-gke.json' - ) +// archiveFiles( +// 'ci/test_suites/authenticators_k8s/output/simplecov-resultset-authnk8s-gke.json' +// ) archiveFiles('cucumber/*/*.*') publishHTML([ @@ -676,10 +579,12 @@ pipeline { spec/reports/*.xml, spec/reports-audit/*.xml, gems/conjur-rack/spec/reports/*.xml, + gems/slosilo/spec/reports/*.xml cucumber/*/features/reports/**/*.xml, ee-test/spec/reports/*.xml, ee-test/spec/reports-audit/*.xml, ee-test/gems/conjur-rack/spec/reports/*.xml, + ee-test/gems/slosilo/spec/reports/*.xml, ee-test/cucumber/*/features/reports/**/*.xml ''' ) @@ -693,42 +598,6 @@ pipeline { } } } // end stage: build and test conjur - - stage('Submit Coverage Report') { - when { - expression { - env.CODE_CLIMATE_PREPARED == "true" - } - } - steps{ - sh 'ci/submit-coverage' - } - } - - stage("Release Conjur images and packages") { - when { - expression { - MODE == "RELEASE" - } - } - steps { - release { billOfMaterialsDirectory, assetDirectory -> - // Publish docker images - sh './publish-images.sh --edge --dockerhub' - - // Create deb and rpm packages - sh 'echo "CONJUR_VERSION=5" >> debify.env' - sh './package.sh' - archiveArtifacts artifacts: '*.deb', fingerprint: true - archiveArtifacts artifacts: '*.rpm', fingerprint: true - sh "cp *.rpm ${assetDirectory}/." - sh "cp *.deb ${assetDirectory}/." - - // Publish deb and rpm packages - sh './publish.sh' - } - } - } } post { @@ -737,7 +606,7 @@ pipeline { // cleanupAndNotify(buildStatus, slackChannel, additionalMessage, ticket) cleanupAndNotify( currentBuild.currentResult, - '#conjur-core', + 'Team - Palm Tree', "${(params.NIGHTLY ? 'nightly' : '')}", true ) @@ -787,16 +656,16 @@ def runConjurTests(run_only_str) { sh 'ci/test authenticators_status' } ], - "authenticators_k8s": [ - "K8s Authenticator - ${env.STAGE_NAME}": { - sh 'ci/test authenticators_k8s' - } - ], - "authenticators_ldap": [ - "LDAP Authenticator - ${env.STAGE_NAME}": { - sh 'ci/test authenticators_ldap' - } - ], +// "authenticators_k8s": [ +// "K8s Authenticator - ${env.STAGE_NAME}": { +// sh 'ci/test authenticators_k8s' +// } +// ], +// "authenticators_ldap": [ +// "LDAP Authenticator - ${env.STAGE_NAME}": { +// sh 'ci/test authenticators_ldap' +// } +// ], "authenticators_oidc": [ "OIDC Authenticator - ${env.STAGE_NAME}": { sh 'summon -f ./ci/test_suites/authenticators_oidc/secrets.yml -e ci ci/test authenticators_oidc' @@ -836,6 +705,11 @@ def runConjurTests(run_only_str) { "Rack - ${env.STAGE_NAME}": { sh 'cd gems/conjur-rack && ./test.sh' } + ], + "slosilo": [ + "Slosilo - ${env.STAGE_NAME}": { + sh 'cd gems/slosilo && ./test.sh' + } ] ] @@ -860,7 +734,7 @@ def runConjurTests(run_only_str) { } def defaultCucumberFilterTags(env) { - if(env.BRANCH_NAME == 'master' || env.TAG_NAME?.trim()) { + if(env.BRANCH_NAME == 'master' || env.BRANCH_NAME == 'conjur-cloud' || env.TAG_NAME?.trim()) { // If this is a master or tag build, we want to run all of the tests. So // we use an empty filter string. return '' diff --git a/app/controllers/concerns/cryptography.rb b/app/controllers/concerns/cryptography.rb new file mode 100644 index 0000000000..0a4ef6581d --- /dev/null +++ b/app/controllers/concerns/cryptography.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# Cryptography is used to define HMAC or hash-based message authentication code +# this is used to hash the api key +module Cryptography + extend ActiveSupport::Concern + module_function + def hmac_api_key(pass, salt) + iter = 20 + key_len = 32 + OpenSSL::KDF.pbkdf2_hmac(pass, salt: salt, iterations: iter, length: key_len, hash: "sha256") + end +end + + diff --git a/app/controllers/edge_controller.rb b/app/controllers/edge_controller.rb new file mode 100644 index 0000000000..9966cc8e59 --- /dev/null +++ b/app/controllers/edge_controller.rb @@ -0,0 +1,231 @@ +# frozen_string_literal: true + +class EdgeController < RestController + include Cryptography + + def slosilo_keys + logger.info(LogMessages::Endpoints::EndpointRequested.new("slosilo_keys")) + allowed_params = %i[account] + options = params.permit(*allowed_params).to_h.symbolize_keys + begin + verify_edge_host(options) + rescue ApplicationController::Forbidden + raise + end + account = options[:account] + + key = Account.token_key(account, "host") + if key.nil? + raise RecordNotFound, "No Slosilo key in DB" + end + + private_key = key.to_der.unpack("H*")[0] + fingerprint = key.fingerprint + variable_to_return = {} + variable_to_return[:privateKey] = private_key + variable_to_return[:fingerprint] = fingerprint + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("slosilo_keys")) + render(json: { "slosiloKeys": [variable_to_return] }) + end + + # Return all secrets within offset-limit frame. Default is 0-1000 + def all_secrets + logger.info(LogMessages::Endpoints::EndpointRequested.new("all_secrets")) + + allowed_params = %i[account limit offset] + options = params.permit(*allowed_params) + .slice(*allowed_params).to_h.symbolize_keys + begin + verify_edge_host(options) + + scope = Resource.where(:resource_id.like(options[:account] + ":variable:data/%")) + if params[:count] == 'true' + sumItems = scope.count('*'.lit) + else + offset = options[:offset] || "0" + limit = options[:limit] || "1000" + validate_scope(limit, offset) + end + rescue ApplicationController::Forbidden + raise + rescue ArgumentError => e + raise ApplicationController::UnprocessableEntity, e.message + end + + if params[:count] == 'true' + results = { count: sumItems } + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("all_secrets:count")) + render(json: results) + else + results = [] + failed = [] + accepts_base64 = String(request.headers['Accept-Encoding']).casecmp?('base64') + if accepts_base64 + response.set_header("Content-Encoding", "base64") + end + + variables = build_variables_map(limit, offset, options) + + variables.each do |id, variable| + variableToReturn = {} + variableToReturn[:id] = id + variableToReturn[:owner] = variable[:owner_id] + variableToReturn[:permissions] = [] + Sequel::Model.db.fetch("SELECT * from permissions where resource_id='" + id + "' AND privilege = 'execute'") do |row| + permission = {} + permission[:privilege] = row[:privilege] + permission[:resource] = row[:resource_id] + permission[:role] = row[:role_id] + permission[:policy] = row[:policy_id] + variableToReturn[:permissions].append(permission) + end + secret_value = Slosilo::EncryptedAttributes.decrypt(variable[:value], aad: id) + variableToReturn[:value] = accepts_base64 ? Base64.strict_encode64(secret_value) : secret_value + variableToReturn[:version] = variable[:version] + variableToReturn[:versions] = [] + value = { + "version": variableToReturn[:version], + "value": variableToReturn[:value] + } + variableToReturn[:versions] << value + begin + JSON.generate(variableToReturn) + results << variableToReturn + rescue => e + failed << { "id": id } + end + + end + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfullyWithLimitAndOffset.new( + "all_secrets", + limit, + offset + )) + if (failed.size > 0) + logger.info(LogMessages::Util::FailedSerializationOfResources.new( + "all_secrets", + limit, + offset, + failed.size, + failed.first + )) + end + render(json: { "secrets": results, "failed": failed }) + end + end + + def all_hosts + logger.info(LogMessages::Endpoints::EndpointRequested.new("all_hosts")) + + allowed_params = %i[account limit offset] + options = params.permit(*allowed_params) + .slice(*allowed_params).to_h.symbolize_keys + begin + verify_edge_host(options) + scope = Role.where(:role_id.like(options[:account] + ":host:data/%")) + if params[:count] == 'true' + sumItems = scope.count('*'.lit) + else + offset = options[:offset] + limit = options[:limit] + validate_scope(limit, offset) + scope = scope.order(:role_id).limit( + (limit || 1000).to_i, + (offset || 0).to_i + ) + end + rescue ApplicationController::Forbidden + raise + rescue ArgumentError => e + raise ApplicationController::UnprocessableEntity, e.message + end + if params[:count] == 'true' + results = { count: sumItems } + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfully.new("all_hosts:count")) + render(json: results) + else + results = [] + hosts = scope.eager(:credentials).all + hosts.each do |host| + hostToReturn = {} + hostToReturn[:id] = host[:role_id] + salt = OpenSSL::Random.random_bytes(32) + hostToReturn[:api_key] = Base64.strict_encode64(hmac_api_key(host.api_key, salt)) + hostToReturn[:salt] = Base64.strict_encode64(salt) + hostToReturn[:memberships] = host.all_roles.all.select { |h| h[:role_id] != (host[:role_id]) } + results << hostToReturn + end + logger.info(LogMessages::Endpoints::EndpointFinishedSuccessfullyWithLimitAndOffset.new( + "all_hosts", + limit, + offset + )) + render(json: { "hosts": results }) + end + end + + private + + def build_variables_map(limit, offset, options) + variables = {} + + Sequel::Model.db.fetch("SELECT * FROM secrets JOIN (SELECT resource_id, owner_id FROM resources WHERE (resource_id LIKE '" + options[:account] + ":variable:data/%') ORDER BY resource_id LIMIT " + limit.to_s + " OFFSET " + offset.to_s + ") AS res ON (res.resource_id = secrets.resource_id)") do |row| + if variables.key?(row[:resource_id]) + if row[:version] > variables[row[:resource_id]][:version] + variables[row[:resource_id]] = row + end + else + variables[row[:resource_id]] = row + end + end + variables + end + + def validate_scope(limit, offset) + if offset || limit + # 'limit' must be an integer greater than 0 and less than 2000 if given + if limit && (!numeric?(limit) || limit.to_i <= 0 || limit.to_i > 2000) + raise ArgumentError, "'limit' contains an invalid value. 'limit' must be a positive integer and less than 2000" + end + # 'offset' must be an integer greater than or equal to 0 if given + if offset && (!numeric?(offset) || offset.to_i.negative?) + raise ArgumentError, "'offset' contains an invalid value. 'offset' must be an integer greater than or equal to 0." + end + end + end + + def verify_edge_host(options) + msg = "" + raise_excep = false + + if %w[conjur cucumber rspec].exclude?(options[:account]) + raise_excep = true + msg = "Account is: #{options[:account]}. Should be one of the following: [conjur cucumber rspec]" + elsif current_user.kind != 'host' + raise_excep = true + msg = "User kind is: #{current_user.kind}. Should be: 'host'" + elsif current_user.role_id.exclude?("host:edge/edge") + raise_excep = true + msg = "Role is: #{current_user.role_id}. Should include: 'host:edge/edge'" + else + role = Role[options[:account] + ':group:edge/edge-hosts'] + unless role&.ancestor_of?(current_user) + raise_excep = true + msg = "Curren user is: #{current_user}. should be member of #{role}" + end + end + + if raise_excep + logger.error( + Errors::Authorization::EndpointNotVisibleToRole.new( + msg + ) + ) + raise Forbidden + end + end + + def numeric? val + val == val.to_i.to_s + end +end diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb new file mode 100644 index 0000000000..4060ce45d0 --- /dev/null +++ b/app/controllers/health_controller.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true +class HealthController < ActionController::API + + def health + if check_db_connection + head :ok + else + head :service_unavailable + end + response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate' + response.headers['Pragma'] = 'no-cache' + response.headers['Expires'] = '0' + end + def check_db_connection + begin + Sequel::Model.db['SELECT 1'].single_value + return true + rescue Exception => e + return false + end + end +end \ No newline at end of file diff --git a/app/db/preview/slosilo.rb b/app/db/preview/slosilo.rb new file mode 100644 index 0000000000..5f7dabfcc4 --- /dev/null +++ b/app/db/preview/slosilo.rb @@ -0,0 +1,9 @@ +module DB + module Preview + class Slosilo_exists + def is_exist? + !Slosilo["authn:conjur"].nil? + end + end + end +end \ No newline at end of file diff --git a/app/domain/authentication/authn_oidc/update_input_with_username_from_id_token.rb b/app/domain/authentication/authn_oidc/update_input_with_username_from_id_token.rb index c5cc648845..903c5ad092 100644 --- a/app/domain/authentication/authn_oidc/update_input_with_username_from_id_token.rb +++ b/app/domain/authentication/authn_oidc/update_input_with_username_from_id_token.rb @@ -99,7 +99,7 @@ def required_variable_names end def validate_conjur_username - if conjur_username.to_s.empty? + if conjur_username.empty? raise Errors::Authentication::AuthnOidc::IdTokenClaimNotFoundOrEmpty, id_token_username_field end @@ -117,7 +117,7 @@ def validate_conjur_username end def conjur_username - @decoded_token[id_token_username_field] + @decoded_token[id_token_username_field].to_s.downcase end def id_token_username_field diff --git a/app/domain/commands/credentials/rotate_api_key.rb b/app/domain/commands/credentials/rotate_api_key.rb index 33bf320ea2..bf296b2973 100644 --- a/app/domain/commands/credentials/rotate_api_key.rb +++ b/app/domain/commands/credentials/rotate_api_key.rb @@ -31,6 +31,13 @@ def rotate_api_key credentials.save end + def static_api_key value = nil + if(!value.nil?) + credentials.static_api_key(value) + credentials.save + end + end + def credentials @role_to_rotate.credentials end diff --git a/app/domain/errors.rb b/app/domain/errors.rb index 5018fe910f..78aa273757 100644 --- a/app/domain/errors.rb +++ b/app/domain/errors.rb @@ -65,6 +65,11 @@ module Authorization code: "CONJ00125E" ) + EndpointNotVisibleToRole = ::Util::TrackableErrorClass.new( + msg: "The requested endpoint is forbidden. Reason: '{0}'", + code: "CONJ00151E" + ) + AccessToResourceIsForbiddenForRole = ::Util::TrackableErrorClass.new( msg: "Role '{0-role}' does not have permissions to access the requested resource '{1-resource}'", code: "CONJ00122E" diff --git a/app/domain/logs.rb b/app/domain/logs.rb index a49d897f2a..d0543fba8f 100644 --- a/app/domain/logs.rb +++ b/app/domain/logs.rb @@ -23,7 +23,23 @@ module Conjur msg: "OpenSSL FIPS mode set to {0}", code: "CONJ00038I" ) + end + + module Endpoints + EndpointRequested = ::Util::TrackableLogMessageClass.new( + msg: "{0} endpoint is called", + code: "CONJ00152I" + ) + + EndpointFinishedSuccessfully = ::Util::TrackableLogMessageClass.new( + msg: "{0} endpoint is finished successfully", + code: "CONJ00153I" + ) + EndpointFinishedSuccessfullyWithLimitAndOffset = ::Util::TrackableLogMessageClass.new( + msg: "{0} endpoint is finished successfully with {1}-limit {2}-offset", + code: "CONJ00155I" + ) end module Authentication @@ -796,6 +812,12 @@ module Util code: "CONJ00026D" ) + FailedSerializationOfResources = ::Util::TrackableLogMessageClass.new( + msg: "Failed serialization of resources in {0} endpoint with {1}-limit {2}-offset " \ + "and {3}-size of failed resources, id of failed resource {4} for example", + code: "CONJ00154I" + ) + end module Config diff --git a/app/domain/token_factory.rb b/app/domain/token_factory.rb index 14d09ce5a3..09199a3178 100644 --- a/app/domain/token_factory.rb +++ b/app/domain/token_factory.rb @@ -12,18 +12,19 @@ class TokenFactory < Dry::Struct MAXIMUM_AUTHENTICATION_TOKEN_EXPIRATION = 5.hours MINIMUM_AUTHENTICATION_TOKEN_EXPIRATION = 0 - def signing_key(account) - slosilo["authn:#{account}".to_sym] || raise(NoSigningKey, account) + def signing_key(username, account) + sloislo_key = host?(username) ? Account.token_key(account, "host") : Account.token_key(account, "user") + sloislo_key || raise(NoSigningKey, Account.token_id(account, host?(username) ? "host" : "user")) end - + def signed_token(account:, username:, host_ttl: Rails.application.config.conjur_config.host_authorization_token_ttl, user_ttl: Rails.application.config.conjur_config.user_authorization_token_ttl) - signing_key(account).issue_jwt( + signing_key(username, account).issue_jwt( sub: username, exp: Time.now + offset( - ttl: username.starts_with?('host/') ? host_ttl : user_ttl + ttl: host?(username) ? host_ttl : user_ttl ) ) end @@ -42,4 +43,8 @@ def parse_ttl(ttl:) # Attempt to coerce a string into integer ttl.to_s.to_i end + + def host?(username) + username.start_with?('host/') + end end diff --git a/app/models/account.rb b/app/models/account.rb index 7daf1ddb11..ee5b29e652 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true +require_relative '../../gems/conjur-rack/lib/conjur/rack/consts' Account = Struct.new(:id) do class << self @@ -17,15 +18,24 @@ def find_or_create_accounts_resource INVALID_ID_CHARS = /[ :]/.freeze + def token_key(account, role) + Slosilo[token_id(account, role)] + end + + def token_id(account, role) + "authn:#{account}:#{role}:current" + end + def create(id, owner_id = nil) - raise Exceptions::RecordExists.new("account", id) if Slosilo["authn:#{id}"] + raise Exceptions::RecordExists.new("account", id) if token_key(id, "host") || token_key(id, "user") if (invalid = INVALID_ID_CHARS.match(id)) raise ArgumentError, 'account name "%s" contains invalid characters (%s)' % [id, invalid] end Role.db.transaction do - Slosilo["authn:#{id}"] = Slosilo::Key.new + Slosilo[token_id(id, "host")] = Slosilo::Key.new + Slosilo[token_id(id, "user")] = Slosilo::Key.new role_id = "#{id}:user:admin" admin_user = Role.create(role_id: role_id) @@ -40,34 +50,34 @@ def create(id, owner_id = nil) end def list - accounts = [] - Slosilo.each do |k,v| - accounts << k - end - accounts.map do |account| - account =~ /\Aauthn:(.+)\z/ - $1 - end.delete_if do |account| - account == "!" + account_set = Set.new + Slosilo.each do |account,_| + account =~ Conjur::Rack::Consts::TOKEN_ID_REGEX + account_set.add($1) unless $1 == "!" end + account_set end end - def token_key - Slosilo["authn:#{id}"] + def token_key(role) + Account.token_key(id, role) + end + + def token_id(role) + Account.token_id(id, role) end def delete # Ensure the signing key exists - slosilo_keystore.adapter.model.with_pk!("authn:#{id}") - + slosilo_keystore.adapter.model.with_pk!(token_id("user")) + slosilo_keystore.adapter.model.with_pk!(token_id("host")) Role["#{id}:user:admin"].destroy Role["#{id}:policy:root"].try(:destroy) Resource["#{id}:user:admin"].try(:destroy) Credentials.where(Sequel.lit("account(role_id)") => id).delete Secret.where(Sequel.lit("account(resource_id)") => id).delete - slosilo_keystore.adapter.model["authn:#{id}"].destroy - + slosilo_keystore.adapter.model[token_id("user")].destroy + slosilo_keystore.adapter.model[token_id("host")].destroy true end diff --git a/app/models/authn_local.rb b/app/models/authn_local.rb index b3957bc837..703913b7c9 100644 --- a/app/models/authn_local.rb +++ b/app/models/authn_local.rb @@ -67,8 +67,8 @@ def issue_token claims claims = claims.slice("account", "sub", "exp", "cidr") (account = claims.delete("account")) || raise("'account' is required") raise "'sub' is required" unless claims['sub'] - - key = Slosilo["authn:#{account}"] + username = claims['sub'] + key = username.starts_with?('host/') ? Account.token_key(account, "host") : Account.token_key(account, "user") if key key.issue_jwt(claims).to_json else diff --git a/app/models/credentials.rb b/app/models/credentials.rb index 0ce92f9658..ed7ec7601c 100644 --- a/app/models/credentials.rb +++ b/app/models/credentials.rb @@ -96,6 +96,13 @@ def rotate_api_key self.api_key = self.class.random_api_key end + def static_api_key(value = nil) + if !value.nil? + self.api_key = value + end + end + + private def expired? diff --git a/bin/conjur-cli/commands/connect_database.rb b/bin/conjur-cli/commands/connect_database.rb index 9cda9c1dbc..af92bab26c 100644 --- a/bin/conjur-cli/commands/connect_database.rb +++ b/bin/conjur-cli/commands/connect_database.rb @@ -31,6 +31,8 @@ def call def test_select db = Sequel::Model.db = Sequel.connect(@database_url) db['select 1'].first + db.disconnect + true rescue false end diff --git a/bin/conjur-cli/commands/server.rb b/bin/conjur-cli/commands/server.rb index 3a90e2eb12..2716d0ea47 100644 --- a/bin/conjur-cli/commands/server.rb +++ b/bin/conjur-cli/commands/server.rb @@ -39,7 +39,7 @@ def call # Start the Conjur API and service # processes fork_server_process - fork_authn_local_process + #fork_authn_local_process fork_rotation_process # Block until all child processes end diff --git a/build.sh b/build.sh index 1531a5e5bd..c3e97c4892 100755 --- a/build.sh +++ b/build.sh @@ -68,10 +68,10 @@ image_doesnt_exist() { [[ "$(docker images -q "$1" 2> /dev/null)" == "" ]] } -if image_doesnt_exist "conjur:$TAG"; then - echo "Building image conjur:$TAG" - docker build -t "conjur:$TAG" . - flatten "conjur:$TAG" +if image_doesnt_exist "conjur-cloud:$TAG"; then + echo "Building image conjur-cloud:$TAG" + docker build -t "conjur-cloud:$TAG" . + flatten "conjur-cloud:$TAG" fi if image_doesnt_exist "conjur-test:$TAG"; then @@ -79,7 +79,7 @@ if image_doesnt_exist "conjur-test:$TAG"; then docker build --build-arg "VERSION=$TAG" -t "conjur-test:$TAG" -f Dockerfile.test . fi -if image_doesnt_exist "conjur-ubi:$TAG"; then - echo "Building image conjur-ubi:$TAG container" - docker build --build-arg "VERSION=$TAG" -t "conjur-ubi:$TAG" -f Dockerfile.ubi . +if image_doesnt_exist "conjur-ubi-cloud:$TAG"; then + echo "Building image conjur-ubi-cloud:$TAG container" + docker build --build-arg "VERSION=$TAG" -t "conjur-ubi-cloud:$TAG" -f Dockerfile.ubi . fi diff --git a/ci/test_suites/authenticators_k8s/build_locally.sh b/ci/test_suites/authenticators_k8s/build_locally.sh index e51d589ec2..0144e1bcca 100755 --- a/ci/test_suites/authenticators_k8s/build_locally.sh +++ b/ci/test_suites/authenticators_k8s/build_locally.sh @@ -28,8 +28,8 @@ cd "$(git rev-parse --show-toplevel)" || exit TAG="$(git rev-parse --short=8 HEAD)" export TAG="$TAG" -docker build --no-cache -t "conjur:$TAG" . -copy_cert "conjur:$TAG" "$sni_cert" -docker build --no-cache -t "registry.tld/conjur:$TAG" . -copy_cert "registry.tld/conjur:$TAG" "$sni_cert" +docker build --no-cache -t "conjur-cloud:$TAG" . +copy_cert "conjur-cloud:$TAG" "$sni_cert" +docker build --no-cache -t "registry.tld/conjur-cloud:$TAG" . +copy_cert "registry.tld/conjur-cloud:$TAG" "$sni_cert" docker build --no-cache --build-arg "VERSION=$TAG" -t "registry.tld/conjur-test:$TAG" -f Dockerfile.test . diff --git a/ci/test_suites/authenticators_k8s/entrypoint.sh b/ci/test_suites/authenticators_k8s/entrypoint.sh index 5e2727b5b9..02f1236ea9 100755 --- a/ci/test_suites/authenticators_k8s/entrypoint.sh +++ b/ci/test_suites/authenticators_k8s/entrypoint.sh @@ -49,7 +49,7 @@ function setupTestEnvironment() { export PLATFORM - export CONJUR_AUTHN_K8S_TAG="${DOCKER_REGISTRY_PATH}/conjur:authn-k8s-$CONJUR_AUTHN_K8S_TEST_NAMESPACE" + export CONJUR_AUTHN_K8S_TAG="${DOCKER_REGISTRY_PATH}/conjur-cloud:authn-k8s-$CONJUR_AUTHN_K8S_TEST_NAMESPACE" export CONJUR_TEST_AUTHN_K8S_TAG="${DOCKER_REGISTRY_PATH}/conjur-test:authn-k8s-$CONJUR_AUTHN_K8S_TEST_NAMESPACE" export CONJUR_AUTHN_K8S_TESTER_TAG="${DOCKER_REGISTRY_PATH}/authn-k8s-tester:$CONJUR_AUTHN_K8S_TEST_NAMESPACE" @@ -101,10 +101,10 @@ function buildDockerImages() { # If the Conjur images aren't present, attempt to pull them from the registry. # If we can't pull them from the registry, see if we have local images we can # tag appropriately. - if ! docker image inspect "$DOCKER_REGISTRY_PATH/conjur:$conjur_version" > /dev/null 2>&1; then - docker pull "$DOCKER_REGISTRY_PATH/conjur:$conjur_version" || \ - docker image inspect "conjur:$conjur_version" > /dev/null && \ - docker tag "conjur:$conjur_version" "$DOCKER_REGISTRY_PATH/conjur:$conjur_version" + if ! docker image inspect "$DOCKER_REGISTRY_PATH/conjur-cloud:$conjur_version" > /dev/null 2>&1; then + docker pull "$DOCKER_REGISTRY_PATH/conjur-cloud:$conjur_version" || \ + docker image inspect "conjur-cloud:$conjur_version" > /dev/null && \ + docker tag "conjur-cloud:$conjur_version" "$DOCKER_REGISTRY_PATH/conjur-cloud:$conjur_version" fi if ! docker image inspect "$DOCKER_REGISTRY_PATH/conjur-test:$conjur_version" > /dev/null 2>&1; then docker pull "$DOCKER_REGISTRY_PATH/conjur-test:$conjur_version" || \ @@ -112,10 +112,10 @@ function buildDockerImages() { docker tag "conjur-test:$conjur_version" "$DOCKER_REGISTRY_PATH/conjur-test:$conjur_version" fi - add_sni_cert_to_image "$DOCKER_REGISTRY_PATH/conjur:$conjur_version" + add_sni_cert_to_image "$DOCKER_REGISTRY_PATH/conjur-cloud:$conjur_version" add_sni_cert_to_image "$DOCKER_REGISTRY_PATH/conjur-test:$conjur_version" - docker tag "$DOCKER_REGISTRY_PATH/conjur:$conjur_version" "$CONJUR_AUTHN_K8S_TAG" + docker tag "$DOCKER_REGISTRY_PATH/conjur-cloud:$conjur_version" "$CONJUR_AUTHN_K8S_TAG" # cukes will be run from this image docker tag "$DOCKER_REGISTRY_PATH/conjur-test:$conjur_version" "$CONJUR_TEST_AUTHN_K8S_TAG" diff --git a/client-app/specs/pacts/zoo_app-animal_service.json b/client-app/specs/pacts/zoo_app-animal_service.json new file mode 100644 index 0000000000..e69de29bb2 diff --git a/config/initializers/rack_middleware.rb b/config/initializers/rack_middleware.rb index e3c1e64eed..fa8b79e8c5 100644 --- a/config/initializers/rack_middleware.rb +++ b/config/initializers/rack_middleware.rb @@ -19,7 +19,8 @@ %r{^/host_factories/hosts$}, %r{^/assets/.*}, %r{^/authenticators$}, - %r{^/$} + %r{^/$}, + %r{^/health$} ]) # We want to ensure requests have an expected content type diff --git a/config/routes.rb b/config/routes.rb index e1f4db4a66..9ce17994cf 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -14,6 +14,7 @@ def matches?(request) scope format: false do get '/' => 'status#index' get '/whoami' => 'status#whoami' + get '/health' => 'health#health' get '/authenticators' => 'authenticate#index' constraints id: /[^\/?]+/ do @@ -75,6 +76,9 @@ def matches?(request) get "/secrets/:account/:kind/*identifier" => 'secrets#show' post "/secrets/:account/:kind/*identifier" => 'secrets#create' get "/secrets" => 'secrets#batch' + get "/edge/secrets/:account" => 'edge#all_secrets' + get "/edge/hosts/:account" => 'edge#all_hosts' + get "/edge/slosilo_keys/:account" => 'edge#slosilo_keys' put "/policies/:account/:kind/*identifier" => 'policies#put' patch "/policies/:account/:kind/*identifier" => 'policies#patch' diff --git a/cucumber.yml b/cucumber.yml index 67b3d17cf5..25d199f682 100644 --- a/cucumber.yml +++ b/cucumber.yml @@ -5,6 +5,7 @@ policy: > api: > --format pretty + --tags "not @skip" -r cucumber/api/features/support/logs_helpers.rb -r cucumber/api/features/step_definitions/logs_steps.rb -r cucumber/_authenticators_common/features/support/conjur_token.rb @@ -149,6 +150,7 @@ authenticators_jwt: > # rotators: > --format pretty + --tags "not @skip" -t 'not @manual' -r cucumber/authenticators/features/support/hooks.rb -r cucumber/api/features/support/step_def_transforms.rb diff --git a/cucumber/_authenticators_common/features/support/hooks.rb b/cucumber/_authenticators_common/features/support/hooks.rb index bc93541569..80e4e0ba86 100644 --- a/cucumber/_authenticators_common/features/support/hooks.rb +++ b/cucumber/_authenticators_common/features/support/hooks.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true +require 'cucumber/_common/slosilo_helper' Before('@skip') do skip_this_scenario @@ -15,12 +16,7 @@ Role.truncate(cascade: true) Secret.truncate Credentials.truncate - - Slosilo.each do |k, _| - unless %w[authn:rspec authn:cucumber].member?(k) - Slosilo.send(:keystore).adapter.model[k].delete - end - end + init_slosilo_keys Account.find_or_create_accounts_resource admin_role = Role.create(role_id: "cucumber:user:admin") diff --git a/cucumber/_common/slosilo_helper.rb b/cucumber/_common/slosilo_helper.rb new file mode 100644 index 0000000000..9e37c47de0 --- /dev/null +++ b/cucumber/_common/slosilo_helper.rb @@ -0,0 +1,13 @@ + +def token_id(account, role) + "authn:#{account}:#{role}:current" +end + +def init_slosilo_keys + slosilo_ids = [token_id("rspec", "host"), token_id("rspec", "user"), token_id("cucumber", "host"), token_id("cucumber", "user")] + Slosilo.each do |k, v| + unless slosilo_ids.member?(k) + Slosilo.send(:keystore).adapter.model[k].delete + end + end +end \ No newline at end of file diff --git a/cucumber/api/features/account_list.feature b/cucumber/api/features/account_list.feature index 1ce8fe76f9..db3db43885 100644 --- a/cucumber/api/features/account_list.feature +++ b/cucumber/api/features/account_list.feature @@ -23,8 +23,10 @@ Feature: List accounts And I permit role "!:user:auditor" to "read" resource "!:webservice:accounts" And I login as "!:user:auditor" Then I successfully GET "/accounts" - And the JSON should include "new-account" - And the JSON should not include "!" + And the JSON response should be: + """ + ["cucumber", "new-account"] + """ @acceptance @logged-in-admin diff --git a/cucumber/api/features/authn_local.feature b/cucumber/api/features/authn_local.feature index 5f4447e9ef..0df9be492d 100644 --- a/cucumber/api/features/authn_local.feature +++ b/cucumber/api/features/authn_local.feature @@ -1,4 +1,4 @@ -@api +@api @skip Feature: Custom Authenticators can obtain access tokens for any role @smoke diff --git a/cucumber/api/features/edge.feature b/cucumber/api/features/edge.feature new file mode 100644 index 0000000000..ac24028d4b --- /dev/null +++ b/cucumber/api/features/edge.feature @@ -0,0 +1,655 @@ +@api +Feature: Fetching secrets from edge endpoint + + Background: + Given I create a new user "some_user" + And I have host "data/some_host1" + And I have host "data/some_host2" + And I have host "data/some_host3" + And I have host "data/some_host4" + And I have host "data/some_host5" + And I have host "other_host1" + And I have host "database/other_host2" + And I have a "variable" resource called "other_sec" + And I am the super-user + And I successfully PUT "/policies/cucumber/policy/root" with body: + """ + - !policy + id: edge + body: + - !group edge-hosts + - !policy + id: edge-abcd1234567890 + body: + - !host + id: edge-host-abcd1234567890 + annotations: + authn/api-key: true + + - !grant + role: !group edge/edge-hosts + members: + - !host edge/edge-abcd1234567890/edge-host-abcd1234567890 + + - !policy + id: data + body: + - !variable secret1 + - !variable secret2 + - !variable secret3 + - !variable secret4 + - !variable secret5 + - !variable secret6 + - !permit + role: !host some_host1 + privilege: [ execute ] + resource: !variable secret1 + - !permit + role: !host some_host2 + privilege: [ execute ] + resource: !variable secret1 + - !permit + role: !host some_host3 + privilege: [ read ] + resource: !variable secret1 + - !permit + role: !host some_host4 + privilege: [ write ] + resource: !variable secret1 + """ + And I add the secret value "s1" to the resource "cucumber:variable:data/secret1" + And I add the secret value "s2" to the resource "cucumber:variable:data/secret2" + And I add the secret value "s3" to the resource "cucumber:variable:data/secret3" + And I add the secret value "s4" to the resource "cucumber:variable:data/secret4" + And I add the secret value "s5" to the resource "cucumber:variable:data/secret5" + # secret6 has no value on purpose. Endpoint `all_secrets` should not return it + And I log out + + # Slosilo key + ######### + @acceptance + Scenario: Fetching key with edge host return 200 OK with json result + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + When I GET "/edge/slosilo_keys/cucumber" + Then the HTTP response status code is 200 + And the JSON at "slosiloKeys" should have 1 entries + And the JSON should have "slosiloKeys/0/fingerprint" + And the JSON at "slosiloKeys/0/fingerprint" should be a string + And the JSON should have "slosiloKeys/0/privateKey" + And the JSON at "slosiloKeys/0/privateKey" should be a string + + @negative @acceptance + Scenario: Fetching hosts with non edge host return 403 + Given I login as "some_user" + When I GET "/edge/slosilo_keys/cucumber" + Then the HTTP response status code is 403 + Given I login as "host/data/some_host1" + When I GET "/edge/slosilo_keys/cucumber" + Then the HTTP response status code is 403 + Given I am the super-user + When I GET "/edge/slosilo_keys/cucumber" + Then the HTTP response status code is 403 + #test wrong account name + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + When I GET "/edge/slosilo_keys/cucumber2" + Then the HTTP response status code is 403 + + + # Secrets + ######### + + @acceptance @smoke + Scenario: Fetching all secrets with edge host return 200 OK with json results + + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + When I GET "/edge/secrets/cucumber" + Then the HTTP response status code is 200 + And the JSON should be: + """ + {"secrets":[ + { + "id": "cucumber:variable:data/secret1", + "owner": "cucumber:policy:data", + "permissions": [{ + "policy": "cucumber:policy:root", + "privilege": "execute", + "resource": "cucumber:variable:data/secret1", + "role": "cucumber:host:data/some_host1" + }, + { + "policy": "cucumber:policy:root", + "privilege": "execute", + "resource": "cucumber:variable:data/secret1", + "role": "cucumber:host:data/some_host2" + } + ], + "value": "s1", + "version": 1, + "versions": [ + { + "value": "s1", + "version": 1 + } + ] + }, + { + "id": "cucumber:variable:data/secret2", + "owner": "cucumber:policy:data", + "permissions": [], + "value": "s2", + "version": 1, + "versions": [ + { + "value": "s2", + "version": 1 + } + ] + }, + { + "id": "cucumber:variable:data/secret3", + "owner": "cucumber:policy:data", + "permissions": [], + "value": "s3", + "version": 1, + "versions": [ + { + "value": "s3", + "version": 1 + } + ] + }, + { + "id": "cucumber:variable:data/secret4", + "owner": "cucumber:policy:data", + "permissions": [], + "value": "s4", + "version": 1, + "versions": [ + { + "value": "s4", + "version": 1 + } + ] + }, + { + "id": "cucumber:variable:data/secret5", + "owner": "cucumber:policy:data", + "permissions": [], + "value": "s5", + "version": 1, + "versions": [ + { + "value": "s5", + "version": 1 + } + ] + } + ], + "failed": []} + """ + + @negative @acceptance + Scenario: Fetching secrets with non edge host return 403 error + + Given I login as "some_user" + When I GET "/edge/secrets/cucumber" + Then the HTTP response status code is 403 + Given I login as "host/data/some_host1" + When I GET "/edge/secrets/cucumber" + Then the HTTP response status code is 403 + Given I am the super-user + When I GET "/edge/secrets/cucumber" + Then the HTTP response status code is 403 + + + @acceptance + Scenario: Fetching secrets by batch with edge host return right json every batch call + + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + When I GET "/edge/secrets/cucumber" with parameters: + """ + limit: 2 + offset: 0 + """ + Then the HTTP response status code is 200 + And the JSON should be: + """ + {"secrets":[ + { + "id": "cucumber:variable:data/secret1", + "owner": "cucumber:policy:data", + "permissions": [{ + "policy": "cucumber:policy:root", + "privilege": "execute", + "resource": "cucumber:variable:data/secret1", + "role": "cucumber:host:data/some_host1" + }, + { + "policy": "cucumber:policy:root", + "privilege": "execute", + "resource": "cucumber:variable:data/secret1", + "role": "cucumber:host:data/some_host2" + } + ], + "value": "s1", + "version": 1, + "versions": [ + { + "value": "s1", + "version": 1 + } + ] + }, + { + "id": "cucumber:variable:data/secret2", + "owner": "cucumber:policy:data", + "permissions": [], + "value": "s2", + "version": 1, + "versions": [ + { + "value": "s2", + "version": 1 + } + ] + } + ], + "failed": [] + } + """ + When I GET "/edge/secrets/cucumber" with parameters: + """ + limit: 2 + offset: 2 + """ + Then the HTTP response status code is 200 + And the JSON should be: + """ + {"secrets":[ + { + "id": "cucumber:variable:data/secret3", + "owner": "cucumber:policy:data", + "permissions": [ + ], + "value": "s3", + "version": 1, + "versions": [ + { + "value": "s3", + "version": 1 + } + ] + }, + { + "id": "cucumber:variable:data/secret4", + "owner": "cucumber:policy:data", + "permissions": [ + ], + "value": "s4", + "version": 1, + "versions": [ + { + "value": "s4", + "version": 1 + } + ] + } + ], + "failed":[]} + """ + When I GET "/edge/secrets/cucumber" with parameters: + """ + limit: 1000 + offset: 4 + """ + Then the HTTP response status code is 200 + And the JSON should be: + """ + {"secrets":[ + { + "id": "cucumber:variable:data/secret5", + "owner": "cucumber:policy:data", + "permissions": [ + ], + "value": "s5", + "version": 1, + "versions": [ + { + "value": "s5", + "version": 1 + } + ] + } + ], + "failed":[]} + """ + + @acceptance + Scenario: Fetching secrets by batch with edge host return right number of results + + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + When I GET "/edge/secrets/cucumber" with parameters: + """ + limit: 2 + offset: 0 + """ + Then the HTTP response status code is 200 + And the JSON at "secrets" should have 2 entries + When I GET "/edge/secrets/cucumber" with parameters: + """ + limit: 10 + offset: 2 + """ + Then the HTTP response status code is 200 + And the JSON at "secrets" should have 3 entries + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + When I GET "/edge/secrets/cucumber" with parameters: + """ + offset: 0 + """ + Then the HTTP response status code is 200 + And the JSON at "secrets" should have 5 entries + When I GET "/edge/secrets/cucumber" with parameters: + """ + offset: 2 + """ + Then the HTTP response status code is 200 + And the JSON at "secrets" should have 3 entries + When I GET "/edge/secrets/cucumber" with parameters: + """ + limit: 2 + """ + Then the HTTP response status code is 200 + And the JSON at "secrets" should have 2 entries + When I GET "/edge/secrets/cucumber" with parameters: + """ + limit: 6 + """ + Then the HTTP response status code is 200 + And the JSON at "secrets" should have 5 entries + When I GET "/edge/secrets/cucumber" with parameters: + """ + limit: 2000 + """ + Then the HTTP response status code is 200 + And the JSON at "secrets" should have 5 entries + When I GET "/edge/secrets/cucumber" with parameters: + """ + limit: 0 + """ + Then the HTTP response status code is 422 + When I GET "/edge/secrets/cucumber" with parameters: + """ + limit: 2001 + """ + Then the HTTP response status code is 422 + + @acceptance + Scenario: Fetching secrets count + + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + When I successfully GET "/edge/secrets/cucumber?count=true" + Then I receive a count of 6 + + @acceptance + Scenario: Fetching secrets count with limit has no effect + + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + When I successfully GET "/edge/secrets/cucumber?count=true&limit=2&offset=0" + Then I receive a count of 6 + + @acceptance + Scenario: Fetching special characters secret with edge host and Accept-Encoding base64 return 200 OK with json results + + Given I login as "some_user" + And I add the secret value "s1±\" to the resource "cucumber:variable:data/secret1" + And I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + And I set the "Accept-Encoding" header to "base64" + When I GET "/edge/secrets/cucumber" with parameters: + """ + limit: 1 + """ + Then the HTTP response status code is 200 + And the JSON should be: + """ + {"secrets":[ + { + "id": "cucumber:variable:data/secret1", + "owner": "cucumber:policy:data", + "permissions": [{ + "policy": "cucumber:policy:root", + "privilege": "execute", + "resource": "cucumber:variable:data/secret1", + "role": "cucumber:host:data/some_host1" + }, + { + "policy": "cucumber:policy:root", + "privilege": "execute", + "resource": "cucumber:variable:data/secret1", + "role": "cucumber:host:data/some_host2" + } + ], + "value": "czHCsVw=", + "version": 2, + "versions": [ + { + "value": "czHCsVw=", + "version": 2 + } + ] + } + ], + "failed":[]} + """ + + @negative @acceptance + Scenario: Fetching all secrets with edge host without Accept-Encoding base64 and special character secret, return 500 + + Given I login as "some_user" + And I add the secret value "s1±" to the resource "cucumber:variable:data/secret1" + And I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + When I GET "/edge/secrets/cucumber" + Then the HTTP response status code is 200 + And the JSON should be: + """ + {"secrets":[ + { + "id": "cucumber:variable:data/secret2", + "owner": "cucumber:policy:data", + "permissions": [], + "value": "s2", + "version": 1, + "versions": [ + { + "value": "s2", + "version": 1 + } + ] + }, + { + "id": "cucumber:variable:data/secret3", + "owner": "cucumber:policy:data", + "permissions": [], + "value": "s3", + "version": 1, + "versions": [ + { + "value": "s3", + "version": 1 + } + ] + }, + { + "id": "cucumber:variable:data/secret4", + "owner": "cucumber:policy:data", + "permissions": [], + "value": "s4", + "version": 1, + "versions": [ + { + "value": "s4", + "version": 1 + } + ] + }, + { + "id": "cucumber:variable:data/secret5", + "owner": "cucumber:policy:data", + "permissions": [], + "value": "s5", + "version": 1, + "versions": [ + { + "value": "s5", + "version": 1 + } + ] + } + ], + "failed":[ + {"id":"cucumber:variable:data/secret1"} + ]} + """ + + @acceptance + Scenario: Fetching special character secret1 with edge host without Accept-Encoding base64, return 200 and json result with escaping + + Given I login as "some_user" + And I add the secret value "s1\" to the resource "cucumber:variable:data/secret1" + And I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + When I GET "/edge/secrets/cucumber" with parameters: + """ + limit: 1 + """ + Then the HTTP response status code is 200 + And the JSON should be: + """ + {"secrets":[ + { + "id": "cucumber:variable:data/secret1", + "owner": "cucumber:policy:data", + "permissions": [{ + "policy": "cucumber:policy:root", + "privilege": "execute", + "resource": "cucumber:variable:data/secret1", + "role": "cucumber:host:data/some_host1" + }, + { + "policy": "cucumber:policy:root", + "privilege": "execute", + "resource": "cucumber:variable:data/secret1", + "role": "cucumber:host:data/some_host2" + }], + "value": "s1\\", + "version": 2, + "versions": [ + { + "value": "s1\\", + "version": 2 + } + ] + } + ], + "failed":[]} + """ + + # Hosts + ####### + + @acceptance @smoke + Scenario: Fetching hosts with edge host return 200 OK + + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + When I GET "/edge/hosts/cucumber" + Then the HTTP response status code is 200 + And the JSON response at "hosts" should have 5 entries + And the JSON response should not have "database" + And the JSON response should not have "other_host" + + @acceptance + Scenario: Fetching hosts with parameters + + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + When I GET "/edge/hosts/cucumber" with parameters: + """ + limit: 2 + offset: 0 + """ + Then the HTTP response status code is 200 + And the JSON at "hosts" should have 2 entries + When I GET "/edge/hosts/cucumber" with parameters: + """ + limit: 10 + offset: 2 + """ + Then the HTTP response status code is 200 + And the JSON at "hosts" should have 3 entries + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + When I GET "/edge/hosts/cucumber" with parameters: + """ + offset: 0 + """ + Then the HTTP response status code is 200 + And the JSON at "hosts" should have 5 entries + When I GET "/edge/hosts/cucumber" with parameters: + """ + offset: 2 + """ + Then the HTTP response status code is 200 + And the JSON at "hosts" should have 3 entries + When I GET "/edge/hosts/cucumber" with parameters: + """ + limit: 2 + """ + Then the HTTP response status code is 200 + And the JSON at "hosts" should have 2 entries + When I GET "/edge/hosts/cucumber" with parameters: + """ + limit: 5 + """ + Then the HTTP response status code is 200 + And the JSON at "hosts" should have 5 entries + When I GET "/edge/hosts/cucumber" with parameters: + """ + limit: 2000 + """ + Then the HTTP response status code is 200 + And the JSON at "hosts" should have 5 entries + When I GET "/edge/hosts/cucumber" with parameters: + """ + limit: 0 + """ + Then the HTTP response status code is 422 + When I GET "/edge/hosts/cucumber" with parameters: + """ + limit: 2001 + """ + Then the HTTP response status code is 422 + + @acceptance + Scenario: Fetching hosts count + + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + When I successfully GET "/edge/hosts/cucumber?count=true" + Then I receive a count of 5 + + @acceptance + Scenario: Fetching hosts count with limit has no effect + + Given I login as "host/edge/edge-abcd1234567890/edge-host-abcd1234567890" + When I successfully GET "/edge/hosts/cucumber?count=true&limit=2&offset=0" + Then I receive a count of 5 + + + @negative @acceptance + Scenario: Fetching hosts with non edge host return 403 + + Given I login as "some_user" + When I GET "/edge/hosts/cucumber" + Then the HTTP response status code is 403 + Given I login as "host/data/some_host1" + When I GET "/edge/hosts/cucumber" + Then the HTTP response status code is 403 + Given I am the super-user + When I GET "/edge/hosts/cucumber" + Then the HTTP response status code is 403 diff --git a/cucumber/api/features/health_api.feature b/cucumber/api/features/health_api.feature new file mode 100644 index 0000000000..37766583fe --- /dev/null +++ b/cucumber/api/features/health_api.feature @@ -0,0 +1,10 @@ +@api +Feature: Status page + + The health route is a simple health actuator that verifies that the API status working + + @smoke + Scenario: GET /health is reachable. + + When I GET the health route + Then the health route is reachable \ No newline at end of file diff --git a/cucumber/api/features/step_definitions/authn_local_steps.rb b/cucumber/api/features/step_definitions/authn_local_steps.rb index 69af2b367c..54d4859c48 100644 --- a/cucumber/api/features/step_definitions/authn_local_steps.rb +++ b/cucumber/api/features/step_definitions/authn_local_steps.rb @@ -8,8 +8,8 @@ Then(/^I obtain an access token for "([^"]*)" in account "([^"]*)"$/) do |user_id, account| expect(token_payload['sub']).to eq(user_id) - - expect(token_protected['kid']).to eq(Slosilo["authn:#{account}"].fingerprint) + slosilo_key = user_id.starts_with?('host/') ? Account.token_key(account, "host") : Account.token_key(account, "user") + expect(token_protected['kid']).to eq(slosilo_key.fingerprint) end Then(/^the access token expires at (\d+)$/) do |exp| diff --git a/cucumber/api/features/step_definitions/health_steps.rb b/cucumber/api/features/step_definitions/health_steps.rb new file mode 100644 index 0000000000..506cd0956c --- /dev/null +++ b/cucumber/api/features/step_definitions/health_steps.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +When(/^I GET the health route$/) do + @response = RestClient.get(Conjur.configuration.appliance_url + "/health") +end + +Then(/^the health route is reachable$/) do + expect(@response.code).to eq(200) +end diff --git a/cucumber/api/features/step_definitions/user_steps.rb b/cucumber/api/features/step_definitions/user_steps.rb index 7fcbeb26d8..086b101908 100644 --- a/cucumber/api/features/step_definitions/user_steps.rb +++ b/cucumber/api/features/step_definitions/user_steps.rb @@ -35,7 +35,7 @@ Given("I login as {string}") do |login| if host?(login) - loginid = login.split('/')[1] + loginid = login.slice(login.index('/')+1, login.length) roleid = (login.include?(":") ? login : "cucumber:host:#{loginid}") else roleid = (login.include?(":") ? login : "cucumber:user:#{login}") diff --git a/cucumber/api/features/support/env.rb b/cucumber/api/features/support/env.rb index edabb5a5db..f81b2a694a 100644 --- a/cucumber/api/features/support/env.rb +++ b/cucumber/api/features/support/env.rb @@ -17,6 +17,7 @@ # per Rafal's request. It could be deleted were it not for that. ENV['CONJUR_APPLIANCE_URL'] ||= Utils.start_local_server -Slosilo["authn:cucumber"] ||= Slosilo::Key.new +Slosilo["authn:cucumber:user:current"] ||= Slosilo::Key.new +Slosilo["authn:cucumber:host:current"] ||= Slosilo::Key.new JsonSpec.excluded_keys = %w[created_at updated_at] diff --git a/cucumber/api/features/support/hooks.rb b/cucumber/api/features/support/hooks.rb index 4e47f2f2ef..13e05ec8ca 100644 --- a/cucumber/api/features/support/hooks.rb +++ b/cucumber/api/features/support/hooks.rb @@ -4,6 +4,7 @@ # require 'haikunator' require 'fileutils' +require 'cucumber/_common/slosilo_helper' # Reset the DB between each test # @@ -17,12 +18,8 @@ Secret.truncate Credentials.truncate - Slosilo.each do |k,v| - unless %w[authn:rspec authn:cucumber].member?(k) - Slosilo.send(:keystore).adapter.model[k].delete - end - end - + init_slosilo_keys + Account.find_or_create_accounts_resource admin_role = Role.create(role_id: "cucumber:user:admin") Credentials.new(role: admin_role).save(raise_on_save_failure: true) diff --git a/cucumber/api/features/support/rest_helpers.rb b/cucumber/api/features/support/rest_helpers.rb index b247c30430..7a80d7a09b 100644 --- a/cucumber/api/features/support/rest_helpers.rb +++ b/cucumber/api/features/support/rest_helpers.rb @@ -289,9 +289,9 @@ def current_user_api_key def current_user_credentials username = @current_user.login # Configure Slosilo to produce valid access tokens - slosilo = Slosilo["authn:#{@current_user.account}"] ||= Slosilo::Key.new + slosilo_user = Slosilo["authn:#{@current_user.account}:user:current"] ||= Slosilo::Key.new # NOTE: 'iat' (issueat) is expected to be autogenerated - token = slosilo.issue_jwt(sub: username) + token = slosilo_user.issue_jwt(sub: username) user_credentials(username, token) end diff --git a/cucumber/authenticators/features/support/hooks.rb b/cucumber/authenticators/features/support/hooks.rb index 6c164a773d..3a3c27e928 100644 --- a/cucumber/authenticators/features/support/hooks.rb +++ b/cucumber/authenticators/features/support/hooks.rb @@ -4,6 +4,8 @@ # # Prior to this hook, our tests had hidden coupling. This ensures each test is # run independently. + +require 'cucumber/_common/slosilo_helper' Before do @user_index = 0 @@ -11,11 +13,7 @@ Secret.truncate Credentials.truncate - Slosilo.each do |k, _| - unless %w[authn:rspec authn:cucumber].member?(k) - Slosilo.send(:keystore).adapter.model[k].delete - end - end + init_slosilo_keys admin_role = Role.create(role_id: "cucumber:user:admin") creds = Credentials.new(role: admin_role) diff --git a/cucumber/policy/features/support/hooks.rb b/cucumber/policy/features/support/hooks.rb index e8742d5d59..efa7add043 100644 --- a/cucumber/policy/features/support/hooks.rb +++ b/cucumber/policy/features/support/hooks.rb @@ -4,6 +4,7 @@ # require 'haikunator' require 'fileutils' +require 'cucumber/_common/slosilo_helper' Before do |scenario| @scenario_name = scenario.name @@ -24,11 +25,7 @@ Secret.truncate Credentials.truncate - Slosilo.each do |k, v| - unless %w[authn:rspec authn:cucumber].member?(k) - Slosilo.send(:keystore).adapter.model[k].delete - end - end + init_slosilo_keys Account.find_or_create_accounts_resource admin_role = Role.create(role_id: "cucumber:user:admin") diff --git a/db/migrate/20230216212349_add_index_to_resources.rb b/db/migrate/20230216212349_add_index_to_resources.rb new file mode 100644 index 0000000000..7b5fa4d2f9 --- /dev/null +++ b/db/migrate/20230216212349_add_index_to_resources.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +Sequel.migration do + up do + execute <<-SQL + CREATE INDEX resources_gin_trgm_idx ON resources(resource_id text_pattern_ops); + SQL + $$; + end + down do + execute "DROP INDEX IF EXISTS resources_gin_trgm_idx" + end +end diff --git a/db/migrate/20230517125717_add_slosilo_for_users_and_hosts.rb b/db/migrate/20230517125717_add_slosilo_for_users_and_hosts.rb new file mode 100644 index 0000000000..fbc984fd3b --- /dev/null +++ b/db/migrate/20230517125717_add_slosilo_for_users_and_hosts.rb @@ -0,0 +1,17 @@ +require 'rake' +require 'active_record' + +# Slosilo key id changed from authn:account to authn:conjur:host / authn:conjur:user +# Two keys (authn:conjur:host and authn:conjur:user) are added to each account upon adding new account +# This migration should run only in existing tenants that include authn:conjur account + +Sequel.migration do + up do + #update only if 'authn:conjur' account exists + if Slosilo['authn:conjur'] + Rake::Task['slosilo:generate'].execute(name:'authn:conjur:host') + Rake::Task['slosilo:generate'].execute(name:'authn:conjur:user') + run("DELETE FROM slosilo_keystore WHERE (id = 'authn:conjur');") + end + end +end diff --git a/db/migrate/20230613064747_update_prev_curr_slos_key.rb b/db/migrate/20230613064747_update_prev_curr_slos_key.rb new file mode 100644 index 0000000000..fc533120ed --- /dev/null +++ b/db/migrate/20230613064747_update_prev_curr_slos_key.rb @@ -0,0 +1,15 @@ + +# Slosilo key id changed from authn:account:host/user to authn:account:host/user:current/previous +# This migration should run only in existing tenants that include authn:account:host/user keys + +Sequel.migration do + up do + # update only if 'authn:conjur:host' or 'authn:conjur:user' accounts exists + if Slosilo['authn:conjur:host'] || Slosilo['authn:conjur:user'] + Rake::Task['slosilo:generate'].execute(name: 'authn:conjur:host:current') + Rake::Task['slosilo:generate'].execute(name: 'authn:conjur:user:current') + run("DELETE FROM slosilo_keystore WHERE (id = 'authn:conjur:host');") + run("DELETE FROM slosilo_keystore WHERE (id = 'authn:conjur:user');") + end + end +end \ No newline at end of file diff --git a/gems/conjur-rack/lib/conjur/rack/authenticator.rb b/gems/conjur-rack/lib/conjur/rack/authenticator.rb index 902240983b..55e7a259f2 100644 --- a/gems/conjur-rack/lib/conjur/rack/authenticator.rb +++ b/gems/conjur-rack/lib/conjur/rack/authenticator.rb @@ -1,4 +1,5 @@ require "conjur/rack/user" +require "conjur/rack/consts" module Conjur module Rack @@ -41,7 +42,10 @@ class SignatureError < SecurityError end class Forbidden < SecurityError end - + class ConfigurationError < SecurityError + end + class ValidationError < SecurityError + end attr_reader :app, :options # +options+: @@ -100,13 +104,15 @@ def conjur_rack end def validate_token_and_get_account token - failure = SignatureError.new("Unauthorized: Invalid token") - raise failure unless (signer = Slosilo.token_signer token) + raise(SignatureError, 'Unauthorized: Invalid token') unless (signer = Slosilo.token_signer token) if signer == 'own' - ENV['CONJUR_ACCOUNT'] or raise failure + account = ENV['CONJUR_ACCOUNT'] if ENV.has_key?('CONJUR_ACCOUNT') + return account if account && account.to_s.strip.length > 0 + raise(ConfigurationError, "Unauthorized: 'CONJUR_ACCOUNT' environment variable must be set") else - raise failure unless signer =~ /\Aauthn:(.+)\z/ - $1 + match = [] + return match[1] if (match = signer.match(Conjur::Rack::Consts::TOKEN_ID_REGEX)) + raise(ValidationError, 'Unauthorized: Invalid signer') end end diff --git a/gems/conjur-rack/lib/conjur/rack/consts.rb b/gems/conjur-rack/lib/conjur/rack/consts.rb new file mode 100644 index 0000000000..c78d7ee045 --- /dev/null +++ b/gems/conjur-rack/lib/conjur/rack/consts.rb @@ -0,0 +1,7 @@ +module Conjur + module Rack + module Consts + TOKEN_ID_REGEX = /\Aauthn:([^:]+)(?::[^:]+)*\z/ + end + end +end diff --git a/gems/conjur-rack/spec/rack/authenticator_spec.rb b/gems/conjur-rack/spec/rack/authenticator_spec.rb index 6c0a1907bb..00b0a58719 100644 --- a/gems/conjur-rack/spec/rack/authenticator_spec.rb +++ b/gems/conjur-rack/spec/rack/authenticator_spec.rb @@ -81,24 +81,31 @@ context "of a token invalid for authn" do it "returns a 401 error" do allow(Slosilo).to receive(:token_signer).and_return('a-totally-different-key') - expect(call).to return_http 401, "Unauthorized: Invalid token" + expect(call).to return_http 401, "Unauthorized: Invalid signer" end end context "of 'own' token" do + before do + allow(Slosilo).to receive(:token_signer).and_return('own') + end it "returns ENV['CONJUR_ACCOUNT']" do expect(ENV).to receive(:[]).with("CONJUR_ACCOUNT").and_return("test-account") + expect(ENV).to receive(:has_key?).with("CONJUR_ACCOUNT").and_return(true) expect(app).to receive(:call) do |*args| expect(Conjur::Rack.identity?).to be(true) expect(Conjur::Rack.user.account).to eq('test-account') :done end - allow(Slosilo).to receive(:token_signer).and_return('own') expect(call).to eq(:done) end it "requires ENV['CONJUR_ACCOUNT']" do - expect(ENV).to receive(:[]).with("CONJUR_ACCOUNT").and_return(nil) - allow(Slosilo).to receive(:token_signer).and_return('own') - expect(call).to return_http 401, "Unauthorized: Invalid token" + expect(ENV).to receive(:has_key?).with("CONJUR_ACCOUNT").and_return(false) + expect(call).to return_http 401, "Unauthorized: 'CONJUR_ACCOUNT' environment variable must be set" + end + it "ENV['CONJUR_ACCOUNT'] can't be empty" do + expect(ENV).to receive(:has_key?).with("CONJUR_ACCOUNT").and_return(true) + expect(ENV).to receive(:[]).with("CONJUR_ACCOUNT").and_return(' ') + expect(call).to return_http 401, "Unauthorized: 'CONJUR_ACCOUNT' environment variable must be set" end end end @@ -171,11 +178,59 @@ expect { subject.send :verify_authorization_and_get_identity }.to raise_error \ Conjur::Rack::Authenticator::AuthorizationError end + end - def mock_jwt claims - token = Slosilo::JWT.new(claims).add_signature(alg: 'none') {} - allow(subject).to receive(:parsed_token) { token } - allow(Slosilo).to receive(:token_signer).with(token).and_return 'authn:test' + describe '#validate_token_and_get_account' do + context "with 'authn:test' token signer" do + it "returns test account name" do + token = mock_jwt({sub: 'user'}) + allow(Slosilo).to receive(:token_signer).with(token).and_return('authn:test') + res = subject.send(:validate_token_and_get_account, token) + expect(res).to eq("test") + end end + + context "with 'authn:test:host:current' token signer" do + it "returns test account name" do + token = mock_jwt({sub: 'host/host'}) + allow(Slosilo).to receive(:token_signer).with(token).and_return('authn:test:host:current') + res = subject.send(:validate_token_and_get_account, token) + expect(res).to eq("test") + end + end + + context "with 'authn:test:user:current' token signer" do + it "returns test account name" do + token = mock_jwt({sub: 'user'}) + allow(Slosilo).to receive(:token_signer).with(token).and_return('authn:test:user:current') + res = subject.send(:validate_token_and_get_account, token) + expect(res).to eq("test") + end + end + + context "with token signer in wrong format" do + it "raise validation error" do + token = mock_jwt({sub: 'host/host'}) + allow(Slosilo).to receive(:token_signer).with(token).and_return('wrong_account') + expect { subject.send :validate_token_and_get_account, token }.to raise_error \ + Conjur::Rack::Authenticator::ValidationError + end + end + + context "with invalid token signer" do + it "raise error" do + token = mock_jwt({sub: 'host/host'}) + allow(Slosilo).to receive(:token_signer).with(token).and_return(nil) + expect { subject.send :validate_token_and_get_account, token }.to raise_error \ + Conjur::Rack::Authenticator::SignatureError + end + end + end + + def mock_jwt(claims, account = 'authn:test') + token = Slosilo::JWT.new(claims).add_signature(alg: 'none') {} + allow(subject).to receive(:parsed_token) { token } + allow(Slosilo).to receive(:token_signer).with(token).and_return(account) + token end end diff --git a/gems/slosilo/.kateproject b/gems/slosilo/.kateproject new file mode 100644 index 0000000000..4022e4f94e --- /dev/null +++ b/gems/slosilo/.kateproject @@ -0,0 +1,4 @@ +{ + "name": "Slosilo" +, "files": [ { "git": 1 } ] +} diff --git a/gems/slosilo/CHANGELOG.md b/gems/slosilo/CHANGELOG.md new file mode 100644 index 0000000000..d287dc93f5 --- /dev/null +++ b/gems/slosilo/CHANGELOG.md @@ -0,0 +1,25 @@ +# v3.0.1 + + * The symmetric cipher class now encrypts and decrypts in a thread-safe manner. + [cyberark/slosilo#31](https://github.com/cyberark/slosilo/pull/31) + +# v3.0.0 + +* Transition to Ruby 3. Consuming projects based on Ruby 2 shall use slosilo V2.X.X. + +# v2.2.2 + +* Add rake task `slosilo:recalculate_fingerprints` which rehashes the fingerprints in the keystore. +**Note**: After migrating the slosilo keystore, run the above rake task to ensure the fingerprints are correctly hashed. + +# v2.2.1 + +* Use SHA256 algorithm instead of MD5 for public key fingerprints. + +# v2.1.1 + +* Add support for JWT-formatted tokens, with arbitrary expiration. + +# v2.0.1 + +* Fixes a bug that occurs when signing tokens containing Unicode data diff --git a/gems/slosilo/CONTRIBUTING.md b/gems/slosilo/CONTRIBUTING.md new file mode 100644 index 0000000000..7c0a67db14 --- /dev/null +++ b/gems/slosilo/CONTRIBUTING.md @@ -0,0 +1,16 @@ +# Contributing + +For general contribution and community guidelines, please see the [community repo](https://github.com/cyberark/community). + +## Contributing Workflow + +1. [Fork the project](https://help.github.com/en/github/getting-started-with-github/fork-a-repo) +2. [Clone your fork](https://help.github.com/en/github/creating-cloning-and-archiving-repositories/cloning-a-repository) +3. Make local changes to your fork by editing files +3. [Commit your changes](https://help.github.com/en/github/managing-files-in-a-repository/adding-a-file-to-a-repository-using-the-command-line) +4. [Push your local changes to the remote server](https://help.github.com/en/github/using-git/pushing-commits-to-a-remote-repository) +5. [Create new Pull Request](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request-from-a-fork) + +From here your pull request will be reviewed and once you've responded to all +feedback it will be merged into the project. Congratulations, you're a +contributor! diff --git a/gems/slosilo/Gemfile b/gems/slosilo/Gemfile new file mode 100644 index 0000000000..ace56bf311 --- /dev/null +++ b/gems/slosilo/Gemfile @@ -0,0 +1,4 @@ +source 'https://rubygems.org' + +# Specify your gem's dependencies in slosilo.gemspec +gemspec diff --git a/gems/slosilo/LICENSE b/gems/slosilo/LICENSE new file mode 100644 index 0000000000..069db73dc1 --- /dev/null +++ b/gems/slosilo/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2020 CyberArk Software Ltd. All rights reserved. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/gems/slosilo/README.md b/gems/slosilo/README.md new file mode 100644 index 0000000000..743cf6e7f5 --- /dev/null +++ b/gems/slosilo/README.md @@ -0,0 +1,152 @@ +# Slosilo + +Slosilo is providing a ruby interface to some cryptographic primitives: +- symmetric encryption, +- a mixin for easy encryption of object attributes, +- asymmetric encryption and signing, +- a keystore in a postgres sequel db -- it allows easy storage and retrieval of keys, +- a keystore in files. + +## Installation + +Add this line to your application's Gemfile: + + gem 'slosilo' + +And then execute: + + $ bundle + +## Compatibility + +Version 3.0 introduced full transition to Ruby 3. +Consumers who use slosilo in Ruby 2 projects, shall use slosilo V2.X.X. + +Version 2.0 introduced new symmetric encryption scheme using AES-256-GCM +for authenticated encryption. It allows you to provide AAD on all symmetric +encryption primitives. It's also **NOT COMPATIBLE** with CBC used in version <2. + +This means you'll have to migrate all your existing data. There's no easy way to +do this currently provided; it's recommended to create a database migration and +put relevant code fragments in it directly. (This will also have the benefit of making +the migration self-contained.) + +Since symmetric encryption is used in processing asymetrically encrypted messages, +this incompatibility extends to those too. + +## Usage + +### Symmetric encryption + +```ruby +sym = Slosilo::Symmetric.new +key = sym.random_key +# additional authenticated data +message_id = "message 001" +ciphertext = sym.encrypt "secret message", key: key, aad: message_id +``` + +```ruby +sym = Slosilo::Symmetric.new +message = sym.decrypt ciphertext, key: key, aad: message_id +``` + +### Encryption mixin + +```ruby +require 'slosilo' + +class Foo + attr_accessor :foo + attr_encrypted :foo, aad: :id + + def raw_foo + @foo + end + + def id + "unique record id" + end +end + +Slosilo::encryption_key = Slosilo::Symmetric.new.random_key + +obj = Foo.new +obj.foo = "bar" +obj.raw_foo # => "\xC4\xEF\x87\xD3b\xEA\x12\xDF\xD0\xD4hk\xEDJ\v\x1Cr\xF2#\xA3\x11\xA4*k\xB7\x8F\x8F\xC2\xBD\xBB\xFF\xE3" +obj.foo # => "bar" +``` + +You can safely use it in ie. ActiveRecord::Base or Sequel::Model subclasses. + +### Asymmetric encryption and signing + +```ruby +private_key = Slosilo::Key.new +public_key = private_key.public +``` + +#### Key dumping +```ruby +k = public_key.to_s # => "-----BEGIN PUBLIC KEY----- ... +(Slosilo::Key.new k) == public_key # => true +``` + +#### Encryption + +```ruby +encrypted = public_key.encrypt_message "eagle one sees many clouds" +# => "\xA3\x1A\xD2\xFC\xB0 ... + +public_key.decrypt_message encrypted +# => OpenSSL::PKey::RSAError: private key needed. + +private_key.decrypt_message encrypted +# => "eagle one sees many clouds" +``` + +#### Signing + +```ruby +token = private_key.signed_token "missile launch not authorized" +# => {"data"=>"missile launch not authorized", "timestamp"=>"2014-10-13 12:41:25 UTC", "signature"=>"bSImk...DzV3o", "key"=>"455f7ac42d2d483f750b4c380761821d"} + +public_key.token_valid? token # => true + +token["data"] = "missile launch authorized" +public_key.token_valid? token # => false +``` + +### Keystore + +```ruby +Slosilo::encryption_key = ENV['SLOSILO_KEY'] +Slosilo.adapter = Slosilo::Adapters::FileAdapter.new "~/.keys" + +Slosilo[:own] = Slosilo::Key.new +Slosilo[:their] = Slosilo::Key.new File.read("foo.pem") + +msg = Slosilo[:their].encrypt_message 'bar' +p Slosilo[:own].signed_token msg +``` + +### Keystore in database + +Add a migration to create the necessary table: + + require 'slosilo/adapters/sequel_adapter/migration' + +Remember to migrate your database + + $ rake db:migrate + +Then +```ruby +Slosilo.adapter = Slosilo::Adapters::SequelAdapter.new +``` + +## Contributing + +We welcome contributions of all kinds to this repository. For instructions on +how to get started and descriptions of our development workflows, please see our +[contributing guide](CONTRIBUTING.md). diff --git a/gems/slosilo/Rakefile b/gems/slosilo/Rakefile new file mode 100644 index 0000000000..6130cd547f --- /dev/null +++ b/gems/slosilo/Rakefile @@ -0,0 +1,17 @@ +#!/usr/bin/env rake +require "bundler/gem_tasks" + +begin + require 'rspec/core/rake_task' + RSpec::Core::RakeTask.new(:spec) +rescue LoadError + $stderr.puts "RSpec Rake tasks not available in environment #{ENV['RACK_ENV']}" +end + +task :jenkins do + require 'ci/reporter/rake/rspec' + Rake::Task["ci:setup:rspec"].invoke + Rake::Task["spec"].invoke +end + +task :default => :spec diff --git a/gems/slosilo/SECURITY.md b/gems/slosilo/SECURITY.md new file mode 100644 index 0000000000..5315a3953e --- /dev/null +++ b/gems/slosilo/SECURITY.md @@ -0,0 +1,42 @@ +# Security Policies and Procedures + +This document outlines security procedures and general policies for the CyberArk Conjur +suite of tools and products. + + * [Reporting a Bug](#reporting-a-bug) + * [Disclosure Policy](#disclosure-policy) + * [Comments on this Policy](#comments-on-this-policy) + +## Reporting a Bug + +The CyberArk Conjur team and community take all security bugs in the Conjur suite seriously. +Thank you for improving the security of the Conjur suite. We appreciate your efforts and +responsible disclosure and will make every effort to acknowledge your +contributions. + +Report security bugs by emailing the lead maintainers at security@conjur.org. + +The maintainers will acknowledge your email within 2 business days. Subsequently, we will +send a more detailed response within 2 business days of our acknowledgement indicating +the next steps in handling your report. After the initial reply to your report, the security +team will endeavor to keep you informed of the progress towards a fix and full +announcement, and may ask for additional information or guidance. + +Report security bugs in third-party modules to the person or team maintaining +the module. + +## Disclosure Policy + +When the security team receives a security bug report, they will assign it to a +primary handler. This person will coordinate the fix and release process, +involving the following steps: + + * Confirm the problem and determine the affected versions. + * Audit code to find any potential similar problems. + * Prepare fixes for all releases still under maintenance. These fixes will be + released as fast as possible. + +## Comments on this Policy + +If you have suggestions on how this process could be improved please submit a +pull request. diff --git a/gems/slosilo/dev/Dockerfile.dev b/gems/slosilo/dev/Dockerfile.dev new file mode 100644 index 0000000000..219b658bc6 --- /dev/null +++ b/gems/slosilo/dev/Dockerfile.dev @@ -0,0 +1,7 @@ +FROM ruby + +COPY ./ /src/ + +WORKDIR /src + +RUN bundle diff --git a/gems/slosilo/dev/docker-compose.yml b/gems/slosilo/dev/docker-compose.yml new file mode 100644 index 0000000000..233ec2a628 --- /dev/null +++ b/gems/slosilo/dev/docker-compose.yml @@ -0,0 +1,8 @@ +version: '3' +services: + dev: + build: + context: .. + dockerfile: dev/Dockerfile.dev + volumes: + - ../:/src diff --git a/gems/slosilo/lib/slosilo.rb b/gems/slosilo/lib/slosilo.rb new file mode 100644 index 0000000000..49594f9693 --- /dev/null +++ b/gems/slosilo/lib/slosilo.rb @@ -0,0 +1,13 @@ +require "slosilo/jwt" +require "slosilo/version" +require "slosilo/keystore" +require "slosilo/symmetric" +require "slosilo/attr_encrypted" +require "slosilo/random" +require "slosilo/errors" + +if defined? Sequel + require 'slosilo/adapters/sequel_adapter' + Slosilo::adapter = Slosilo::Adapters::SequelAdapter.new +end +Dir[File.join(File.dirname(__FILE__), 'tasks/*.rake')].each { |ext| load ext } if defined?(Rake) diff --git a/gems/slosilo/lib/slosilo/adapters/abstract_adapter.rb b/gems/slosilo/lib/slosilo/adapters/abstract_adapter.rb new file mode 100644 index 0000000000..5dba4cefaa --- /dev/null +++ b/gems/slosilo/lib/slosilo/adapters/abstract_adapter.rb @@ -0,0 +1,23 @@ +require 'slosilo/attr_encrypted' + +module Slosilo + module Adapters + class AbstractAdapter + def get_key id + raise NotImplementedError + end + + def get_by_fingerprint fp + raise NotImplementedError + end + + def put_key id, key + raise NotImplementedError + end + + def each + raise NotImplementedError + end + end + end +end diff --git a/gems/slosilo/lib/slosilo/adapters/file_adapter.rb b/gems/slosilo/lib/slosilo/adapters/file_adapter.rb new file mode 100644 index 0000000000..428995b4c1 --- /dev/null +++ b/gems/slosilo/lib/slosilo/adapters/file_adapter.rb @@ -0,0 +1,42 @@ +require 'slosilo/adapters/abstract_adapter' + +module Slosilo + module Adapters + class FileAdapter < AbstractAdapter + attr_reader :dir + + def initialize(dir) + @dir = dir + @keys = {} + @fingerprints = {} + Dir[File.join(@dir, "*.key")].each do |f| + key = Slosilo::EncryptedAttributes.decrypt File.read(f) + id = File.basename(f, '.key') + key = @keys[id] = Slosilo::Key.new(key) + @fingerprints[key.fingerprint] = id + end + end + + def put_key id, value + raise "id should not contain a period" if id.index('.') + fname = File.join(dir, "#{id}.key") + File.write(fname, Slosilo::EncryptedAttributes.encrypt(value.to_der)) + File.chmod(0400, fname) + @keys[id] = value + end + + def get_key id + @keys[id] + end + + def get_by_fingerprint fp + id = @fingerprints[fp] + [@keys[id], id] + end + + def each(&block) + @keys.each(&block) + end + end + end +end diff --git a/gems/slosilo/lib/slosilo/adapters/memory_adapter.rb b/gems/slosilo/lib/slosilo/adapters/memory_adapter.rb new file mode 100644 index 0000000000..bfac8eeba3 --- /dev/null +++ b/gems/slosilo/lib/slosilo/adapters/memory_adapter.rb @@ -0,0 +1,31 @@ +require 'slosilo/adapters/abstract_adapter' + +module Slosilo + module Adapters + class MemoryAdapter < AbstractAdapter + def initialize + @keys = {} + @fingerprints = {} + end + + def put_key id, key + key = Slosilo::Key.new(key) if key.is_a?(String) + @keys[id] = key + @fingerprints[key.fingerprint] = id + end + + def get_key id + @keys[id] + end + + def get_by_fingerprint fp + id = @fingerprints[fp] + [@keys[id], id] + end + + def each(&block) + @keys.each(&block) + end + end + end +end diff --git a/gems/slosilo/lib/slosilo/adapters/mock_adapter.rb b/gems/slosilo/lib/slosilo/adapters/mock_adapter.rb new file mode 100644 index 0000000000..d62805fda9 --- /dev/null +++ b/gems/slosilo/lib/slosilo/adapters/mock_adapter.rb @@ -0,0 +1,21 @@ +module Slosilo + module Adapters + class MockAdapter < Hash + def initialize + @fp = {} + end + + def put_key id, key + @fp[key.fingerprint] = id + self[id] = key + end + + alias :get_key :[] + + def get_by_fingerprint fp + id = @fp[fp] + [self[id], id] + end + end + end +end diff --git a/gems/slosilo/lib/slosilo/adapters/sequel_adapter.rb b/gems/slosilo/lib/slosilo/adapters/sequel_adapter.rb new file mode 100644 index 0000000000..08a7792074 --- /dev/null +++ b/gems/slosilo/lib/slosilo/adapters/sequel_adapter.rb @@ -0,0 +1,101 @@ +require 'slosilo/adapters/abstract_adapter' + +module Slosilo + module Adapters + class SequelAdapter < AbstractAdapter + def model + @model ||= create_model + end + + def secure? + !Slosilo.encryption_key.nil? + end + + def create_model + model = Sequel::Model(:slosilo_keystore) + model.unrestrict_primary_key + model.attr_encrypted(:key, aad: :id) if secure? + model + end + + def put_key id, value + fail Error::InsecureKeyStorage unless secure? || !value.private? + + attrs = { id: id, key: value.to_der } + attrs[:fingerprint] = value.fingerprint if fingerprint_in_db? + stored = model[id] + if stored + stored.update attrs + else + model.create attrs + end + end + + def get_key id + stored = model[id] + return nil unless stored + Slosilo::Key.new stored.key + end + + def get_by_fingerprint fp + if fingerprint_in_db? + stored = model[fingerprint: fp] + return nil unless stored + [Slosilo::Key.new(stored.key), stored.id] + else + warn "Please migrate to a new database schema using rake slosilo:migrate for efficient fingerprint lookups" + find_by_fingerprint fp + end + end + + def each + model.each do |m| + yield m.id, Slosilo::Key.new(m.key) + end + end + + def recalculate_fingerprints + # Use a transaction to ensure that all fingerprints are updated together. If any update fails, + # we want to rollback all updates. + model.db.transaction do + model.each do |m| + m.update fingerprint: Slosilo::Key.new(m.key).fingerprint + end + end + end + + + def migrate! + unless fingerprint_in_db? + model.db.transaction do + model.db.alter_table :slosilo_keystore do + add_column :fingerprint, String + end + + # reload the schema + model.set_dataset model.dataset + + recalculate_fingerprints + + model.db.alter_table :slosilo_keystore do + set_column_not_null :fingerprint + add_unique_constraint :fingerprint + end + end + end + end + + private + + def fingerprint_in_db? + model.columns.include? :fingerprint + end + + def find_by_fingerprint fp + each do |id, k| + return [k, id] if k.fingerprint == fp + end + end + end + end +end diff --git a/gems/slosilo/lib/slosilo/adapters/sequel_adapter/migration.rb b/gems/slosilo/lib/slosilo/adapters/sequel_adapter/migration.rb new file mode 100644 index 0000000000..7cec637c24 --- /dev/null +++ b/gems/slosilo/lib/slosilo/adapters/sequel_adapter/migration.rb @@ -0,0 +1,52 @@ +require 'sequel' + +module Slosilo + module Adapters::SequelAdapter::Migration + # The default name of the table to hold the keys + DEFAULT_KEYSTORE_TABLE = :slosilo_keystore + + # Sets up default keystore table name + def self.extended(db) + db.keystore_table ||= DEFAULT_KEYSTORE_TABLE + end + + # Keystore table name. If changing this do it immediately after loading the extension. + attr_accessor :keystore_table + + # Create the table for holding keys + def create_keystore_table + # docs say to not use create_table? in migration; + # but we really want this to be robust in case there are any previous installs + # and we can't use table_exists? because it rolls back + create_table? keystore_table do + String :id, primary_key: true + bytea :key, null: false + String :fingerprint, unique: true, null: false + end + end + + # Drop the table + def drop_keystore_table + drop_table keystore_table + end + end + + module Extension + def slosilo_keystore + extend Slosilo::Adapters::SequelAdapter::Migration + end + end + + Sequel::Database.send :include, Extension +end + +Sequel.migration do + up do + slosilo_keystore + create_keystore_table + end + down do + slosilo_keystore + drop_keystore_table + end +end diff --git a/gems/slosilo/lib/slosilo/attr_encrypted.rb b/gems/slosilo/lib/slosilo/attr_encrypted.rb new file mode 100644 index 0000000000..b860dac627 --- /dev/null +++ b/gems/slosilo/lib/slosilo/attr_encrypted.rb @@ -0,0 +1,85 @@ +require 'slosilo/symmetric' + +module Slosilo + # we don't trust the database to keep all backups safe from the prying eyes + # so we encrypt sensitive attributes before storing them + module EncryptedAttributes + module ClassMethods + + # @param options [Hash] + # @option :aad [#to_proc, #to_s] Provide additional authenticated data for + # encryption. This should be something unique to the instance having + # this attribute, such as a primary key; this will ensure that an attacker can't swap + # values around -- trying to decrypt value with a different auth data will fail. + # This means you have to be able to recover it in order to decrypt attributes. + # The following values are accepted: + # + # * Something proc-ish: will be called with self each time auth data is needed. + # * Something stringish: will be to_s-d and used for all instances as auth data. + # Note that this will only prevent swapping in data using another string. + # + # The recommended way to use this option is to pass a proc-ish that identifies the record. + # Note the proc-ish can be a simple method name; for example in case of a Sequel::Model: + # attr_encrypted :secret, aad: :pk + def attr_encrypted *a + options = a.last.is_a?(Hash) ? a.pop : {} + aad = options[:aad] + # note nil.to_s is "", which is exactly the right thing + auth_data = aad.respond_to?(:to_proc) ? aad.to_proc : proc{ |_| aad.to_s } + + # In ruby 3 .arity for #proc returns both 1 and 2, depends on internal #proc + # This method is also being called with aad which is string, in such case the arity is 1 + raise ":aad proc must take two arguments" unless (auth_data.arity.abs == 2 || auth_data.arity.abs == 1) + + # push a module onto the inheritance hierarchy + # this allows calling super in classes + include(accessors = Module.new) + accessors.module_eval do + a.each do |attr| + define_method "#{attr}=" do |value| + super(EncryptedAttributes.encrypt(value, aad: auth_data[self])) + end + define_method attr do + EncryptedAttributes.decrypt(super(), aad: auth_data[self]) + end + end + end + end + + end + + def self.included base + base.extend ClassMethods + end + + class << self + def encrypt value, opts={} + return nil unless value + cipher.encrypt value, key: key, aad: opts[:aad] + end + + def decrypt ctxt, opts={} + return nil unless ctxt + cipher.decrypt ctxt, key: key, aad: opts[:aad] + end + + def key + Slosilo::encryption_key || (raise "Please set Slosilo::encryption_key") + end + + def cipher + @cipher ||= Slosilo::Symmetric.new + end + end + end + + class << self + attr_writer :encryption_key + + def encryption_key + @encryption_key + end + end +end + +Object.send :include, Slosilo::EncryptedAttributes diff --git a/gems/slosilo/lib/slosilo/errors.rb b/gems/slosilo/lib/slosilo/errors.rb new file mode 100644 index 0000000000..abdf35521f --- /dev/null +++ b/gems/slosilo/lib/slosilo/errors.rb @@ -0,0 +1,15 @@ +module Slosilo + class Error < RuntimeError + # An error thrown when attempting to store a private key in an unecrypted + # storage. Set Slosilo.encryption_key to secure the storage or make sure + # to store just the public keys (using Key#public). + class InsecureKeyStorage < Error + def initialize msg = "can't store a private key in a plaintext storage" + super + end + end + + class TokenValidationError < Error + end + end +end diff --git a/gems/slosilo/lib/slosilo/jwt.rb b/gems/slosilo/lib/slosilo/jwt.rb new file mode 100644 index 0000000000..3baf5af790 --- /dev/null +++ b/gems/slosilo/lib/slosilo/jwt.rb @@ -0,0 +1,122 @@ +require 'json' + +module Slosilo + # A JWT-formatted Slosilo token. + # @note This is not intended to be a general-purpose JWT implementation. + class JWT + # Create a new unsigned token with the given claims. + # @param claims [#to_h] claims to embed in this token. + def initialize claims = {} + @claims = JSONHash[claims] + end + + # Parse a token in compact representation + def self.parse_compact raw + load *raw.split('.', 3).map(&Base64.method(:urlsafe_decode64)) + end + + # Parse a token in JSON representation. + # @note only single signature is currently supported. + def self.parse_json raw + raw = JSON.load raw unless raw.respond_to? :to_h + parts = raw.to_h.values_at(*%w(protected payload signature)) + fail ArgumentError, "input not a complete JWT" unless parts.all? + load *parts.map(&Base64.method(:urlsafe_decode64)) + end + + # Add a signature. + # @note currently only a single signature is handled; + # the token will be frozen after this operation. + def add_signature header, &sign + @claims = canonicalize_claims.freeze + @header = JSONHash[header].freeze + @signature = sign[string_to_sign].freeze + freeze + end + + def string_to_sign + [header, claims].map(&method(:encode)).join '.' + end + + # Returns the JSON serialization of this JWT. + def to_json *a + { + protected: encode(header), + payload: encode(claims), + signature: encode(signature) + }.to_json *a + end + + # Returns the compact serialization of this JWT. + def to_s + [header, claims, signature].map(&method(:encode)).join('.') + end + + attr_accessor :claims, :header, :signature + + private + + # Create a JWT token object from existing header, payload, and signature strings. + # @param header [#to_s] URLbase64-encoded representation of the protected header + # @param payload [#to_s] URLbase64-encoded representation of the token payload + # @param signature [#to_s] URLbase64-encoded representation of the signature + def self.load header, payload, signature + self.new(JSONHash.load payload).tap do |token| + token.header = JSONHash.load header + token.signature = signature.to_s.freeze + token.freeze + end + end + + def canonicalize_claims + claims[:iat] = Time.now unless claims.include? :iat + claims[:iat] = claims[:iat].to_time.to_i + claims[:exp] = claims[:exp].to_time.to_i if claims.include? :exp + JSONHash[claims.to_a] + end + + # Convenience method to make the above code clearer. + # Converts to string and urlbase64-encodes. + def encode s + Base64.urlsafe_encode64 s.to_s + end + + # a hash with a possibly frozen JSON stringification + class JSONHash < Hash + def to_s + @repr || to_json + end + + def freeze + @repr = to_json.freeze + super + end + + def self.load raw + self[JSON.load raw.to_s].tap do |h| + h.send :repr=, raw + end + end + + private + + def repr= raw + @repr = raw.freeze + freeze + end + end + end + + # Try to convert by detecting token representation and parsing + def self.JWT raw + if raw.is_a? JWT + raw + elsif raw.respond_to?(:to_h) || raw =~ /\A\s*\{/ + JWT.parse_json raw + else + JWT.parse_compact raw + end + rescue + raise ArgumentError, "invalid value for JWT(): #{raw.inspect}" + end +end diff --git a/gems/slosilo/lib/slosilo/key.rb b/gems/slosilo/lib/slosilo/key.rb new file mode 100644 index 0000000000..54219b0ceb --- /dev/null +++ b/gems/slosilo/lib/slosilo/key.rb @@ -0,0 +1,218 @@ +require 'openssl' +require 'json' +require 'base64' +require 'time' + +require 'slosilo/errors' + +module Slosilo + class Key + def initialize raw_key = nil + @key = if raw_key.is_a? OpenSSL::PKey::RSA + raw_key + elsif !raw_key.nil? + OpenSSL::PKey.read raw_key + else + OpenSSL::PKey::RSA.new 2048 + end + rescue OpenSSL::PKey::PKeyError => e + # old openssl versions used to report ArgumentError + # which arguably makes more sense here, so reraise as that + raise ArgumentError, e, e.backtrace + end + + attr_reader :key + + def cipher + @cipher ||= Slosilo::Symmetric.new + end + + def encrypt plaintext + key = cipher.random_key + ctxt = cipher.encrypt plaintext, key: key + key = @key.public_encrypt key + [ctxt, key] + end + + def encrypt_message plaintext + c, k = encrypt plaintext + k + c + end + + def decrypt ciphertext, skey + key = @key.private_decrypt skey + cipher.decrypt ciphertext, key: key + end + + def decrypt_message ciphertext + k, c = ciphertext.unpack("A256A*") + decrypt c, k + end + + def to_s + @key.public_key.to_pem + end + + def to_der + @to_der ||= @key.to_der + end + + def sign value + sign_string(stringify value) + end + + SIGNATURE_LEN = 256 + + def verify_signature data, signature + signature, salt = signature.unpack("a#{SIGNATURE_LEN}a*") + key.public_decrypt(signature) == hash_function.digest(salt + stringify(data)) + rescue + false + end + + # create a new timestamped and signed token carrying data + def signed_token data + token = { "data" => data, "timestamp" => Time.new.utc.to_s } + token["signature"] = Base64::urlsafe_encode64(sign token) + token["key"] = fingerprint + token + end + + JWT_ALGORITHM = 'conjur.org/slosilo/v2'.freeze + + # Issue a JWT with the given claims. + # `iat` (issued at) claim is automatically added. + # Other interesting claims you can give are: + # - `sub` - token subject, for example a user name; + # - `exp` - expiration time (absolute); + # - `cidr` (Conjur extension) - array of CIDR masks that are accepted to + # make requests that bear this token + def issue_jwt claims + token = Slosilo::JWT.new claims + token.add_signature \ + alg: JWT_ALGORITHM, + kid: fingerprint, + &method(:sign) + token.freeze + end + + DEFAULT_EXPIRATION = 8 * 60 + + def token_valid? token, expiry = DEFAULT_EXPIRATION + return jwt_valid? token if token.respond_to? :header + token = token.clone + expected_key = token.delete "key" + return false if (expected_key and (expected_key != fingerprint)) + signature = Base64::urlsafe_decode64(token.delete "signature") + (Time.parse(token["timestamp"]) + expiry > Time.now) && verify_signature(token, signature) + end + + # Validate a JWT. + # + # Convenience method calling #validate_jwt and returning false if an + # exception is raised. + # + # @param token [JWT] pre-parsed token to verify + # @return [Boolean] + def jwt_valid? token + validate_jwt token + true + rescue + false + end + + # Validate a JWT. + # + # First checks whether algorithm is 'conjur.org/slosilo/v2' and the key id + # matches this key's fingerprint. Then verifies if the token is not expired, + # as indicated by the `exp` claim; in its absence tokens are assumed to + # expire in `iat` + 8 minutes. + # + # If those checks pass, finally the signature is verified. + # + # @raises TokenValidationError if any of the checks fail. + # + # @note It's the responsibility of the caller to examine other claims + # included in the token; consideration needs to be given to handling + # unrecognized claims. + # + # @param token [JWT] pre-parsed token to verify + def validate_jwt token + def err msg + raise Error::TokenValidationError, msg, caller + end + + header = token.header + err 'unrecognized algorithm' unless header['alg'] == JWT_ALGORITHM + err 'mismatched key' if (kid = header['kid']) && kid != fingerprint + iat = Time.at token.claims['iat'] || err('unknown issuing time') + exp = Time.at token.claims['exp'] || (iat + DEFAULT_EXPIRATION) + err 'token expired' if exp <= Time.now + err 'invalid signature' unless verify_signature token.string_to_sign, token.signature + true + end + + def sign_string value + salt = shake_salt + key.private_encrypt(hash_function.digest(salt + value)) + salt + end + + def fingerprint + @fingerprint ||= OpenSSL::Digest::SHA256.hexdigest key.public_key.to_der + end + + def == other + to_der == other.to_der + end + + alias_method :eql?, :== + + def hash + to_der.hash + end + + # return a new key with just the public part of this + def public + Key.new(@key.public_key) + end + + # checks if the keypair contains a private key + def private? + @key.private? + end + + private + + # Note that this is currently somewhat shallow stringification -- + # to implement originating tokens we may need to make it deeper. + def stringify value + string = case value + when Hash + value.to_a.sort.to_json + when String + value + else + value.to_json + end + + # Make sure that the string is ascii_8bit (i.e. raw bytes), and represents + # the utf-8 encoding of the string. This accomplishes two things: it normalizes + # the representation of the string at the byte level (so we don't have an error if + # one username is submitted as ISO-whatever, and the next as UTF-16), and it prevents + # an incompatible encoding error when we concatenate it with the salt. + if string.encoding != Encoding::ASCII_8BIT + string.encode(Encoding::UTF_8).force_encoding(Encoding::ASCII_8BIT) + else + string + end + end + + def shake_salt + Slosilo::Random::salt + end + + def hash_function + @hash_function ||= OpenSSL::Digest::SHA256 + end + end +end diff --git a/gems/slosilo/lib/slosilo/keystore.rb b/gems/slosilo/lib/slosilo/keystore.rb new file mode 100644 index 0000000000..87a13e5970 --- /dev/null +++ b/gems/slosilo/lib/slosilo/keystore.rb @@ -0,0 +1,89 @@ +require 'slosilo/key' + +module Slosilo + class Keystore + def adapter + Slosilo::adapter or raise "No Slosilo adapter is configured or available" + end + + def put id, key + id = id.to_s + fail ArgumentError, "id can't be empty" if id.empty? + adapter.put_key id, key + end + + def get opts + id, fingerprint = opts.is_a?(Hash) ? [nil, opts[:fingerprint]] : [opts, nil] + if id + key = adapter.get_key(id.to_s) + elsif fingerprint + key, _ = get_by_fingerprint(fingerprint) + end + key + end + + def get_by_fingerprint fingerprint + adapter.get_by_fingerprint fingerprint + end + + def each &_ + adapter.each { |k, v| yield k, v } + end + + def any? &block + each do |_, k| + return true if yield k + end + return false + end + end + + class << self + def []= id, value + keystore.put id, value + end + + def [] id + keystore.get id + end + + def each(&block) + keystore.each(&block) + end + + def sign object + self[:own].sign object + end + + def token_valid? token + keystore.any? { |k| k.token_valid? token } + end + + # Looks up the signer by public key fingerprint and checks the validity + # of the signature. If the token is JWT, exp and/or iat claims are also + # verified; the caller is responsible for validating any other claims. + def token_signer token + begin + # see if maybe it's a JWT + token = JWT token + fingerprint = token.header['kid'] + rescue ArgumentError + fingerprint = token['key'] + end + + key, id = keystore.get_by_fingerprint fingerprint + if key && key.token_valid?(token) + return id + else + return nil + end + end + + attr_accessor :adapter + + private + def keystore + @keystore ||= Keystore.new + end + end +end diff --git a/gems/slosilo/lib/slosilo/random.rb b/gems/slosilo/lib/slosilo/random.rb new file mode 100644 index 0000000000..d78ae58578 --- /dev/null +++ b/gems/slosilo/lib/slosilo/random.rb @@ -0,0 +1,11 @@ +require 'openssl' + +module Slosilo + module Random + class << self + def salt + OpenSSL::Random::random_bytes 32 + end + end + end +end diff --git a/gems/slosilo/lib/slosilo/symmetric.rb b/gems/slosilo/lib/slosilo/symmetric.rb new file mode 100644 index 0000000000..7c783f0bc0 --- /dev/null +++ b/gems/slosilo/lib/slosilo/symmetric.rb @@ -0,0 +1,63 @@ +module Slosilo + class Symmetric + VERSION_MAGIC = 'G' + TAG_LENGTH = 16 + + def initialize + @cipher = OpenSSL::Cipher.new 'aes-256-gcm' # NB: has to be lower case for whatever reason. + @cipher_mutex = Mutex.new + end + + # This lets us do a final sanity check in migrations from older encryption versions + def cipher_name + @cipher.name + end + + def encrypt plaintext, opts = {} + # All of these operations in OpenSSL must occur atomically, so we + # synchronize their access to make this step thread-safe. + @cipher_mutex.synchronize do + @cipher.reset + @cipher.encrypt + @cipher.key = (opts[:key] or raise("missing :key option")) + @cipher.iv = iv = random_iv + @cipher.auth_data = opts[:aad] || "" # Nothing good happens if you set this to nil, or don't set it at all + ctext = @cipher.update(plaintext) + @cipher.final + tag = @cipher.auth_tag(TAG_LENGTH) + "#{VERSION_MAGIC}#{tag}#{iv}#{ctext}" + end + end + + def decrypt ciphertext, opts = {} + version, tag, iv, ctext = unpack ciphertext + + raise "Invalid version magic: expected #{VERSION_MAGIC} but was #{version}" unless version == VERSION_MAGIC + + # All of these operations in OpenSSL must occur atomically, so we + # synchronize their access to make this step thread-safe. + @cipher_mutex.synchronize do + @cipher.reset + @cipher.decrypt + @cipher.key = opts[:key] + @cipher.iv = iv + @cipher.auth_tag = tag + @cipher.auth_data = opts[:aad] || "" + @cipher.update(ctext) + @cipher.final + end + end + + def random_iv + @cipher.random_iv + end + + def random_key + @cipher.random_key + end + + private + # return tag, iv, ctext + def unpack msg + msg.unpack "aa#{TAG_LENGTH}a#{@cipher.iv_len}a*" + end + end +end diff --git a/gems/slosilo/lib/slosilo/version.rb b/gems/slosilo/lib/slosilo/version.rb new file mode 100644 index 0000000000..27091ff1a3 --- /dev/null +++ b/gems/slosilo/lib/slosilo/version.rb @@ -0,0 +1,3 @@ +module Slosilo + VERSION = "3.0.1" +end diff --git a/gems/slosilo/lib/tasks/slosilo.rake b/gems/slosilo/lib/tasks/slosilo.rake new file mode 100644 index 0000000000..5cf153faa4 --- /dev/null +++ b/gems/slosilo/lib/tasks/slosilo.rake @@ -0,0 +1,32 @@ +namespace :slosilo do + desc "Dump a public key" + task :dump, [:name] => :environment do |t, args| + args.with_defaults(:name => :own) + puts Slosilo[args[:name]] + end + + desc "Enroll a key" + task :enroll, [:name] => :environment do |t, args| + key = Slosilo::Key.new STDIN.read + Slosilo[args[:name]] = key + puts key + end + + desc "Generate a key pair" + task :generate, [:name] => :environment do |t, args| + args.with_defaults(:name => :own) + key = Slosilo::Key.new + Slosilo[args[:name]] = key + puts key + end + + desc "Migrate to a new database schema" + task :migrate => :environment do |t| + Slosilo.adapter.migrate! + end + + desc "Recalculate fingerprints in keystore" + task :recalculate_fingerprints => :environment do |t| + Slosilo.adapter.recalculate_fingerprints + end +end diff --git a/gems/slosilo/slosilo.gemspec b/gems/slosilo/slosilo.gemspec new file mode 100644 index 0000000000..f0df057074 --- /dev/null +++ b/gems/slosilo/slosilo.gemspec @@ -0,0 +1,34 @@ +# -*- encoding: utf-8 -*- +begin + require File.expand_path('../lib/slosilo/version', __FILE__) +rescue LoadError + # so that bundle can be run without the app code + module Slosilo + VERSION = '0.0.0' + end +end + +Gem::Specification.new do |gem| + gem.name = "slosilo" + gem.version = Slosilo::VERSION + gem.authors = ["Cyberark R&D"] + gem.summary = %q{Store SSL keys in a database} + gem.description = %q{This gem provides an easy way of storing and retrieving encryption keys in the database.} + gem.homepage = "" + + gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) } + gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) + + gem.require_paths = ["lib"] + gem.required_ruby_version = '>= 3.0.0' + + gem.add_development_dependency 'rake' + gem.add_development_dependency 'rspec', '~> 3.0' + gem.add_development_dependency 'ci_reporter_rspec' + gem.add_development_dependency 'simplecov' + gem.add_development_dependency 'simplecov-cobertura' + gem.add_development_dependency 'io-grab', '~> 0.0.1' + gem.add_development_dependency 'sequel' # for sequel tests + gem.add_development_dependency 'sqlite3' # for sequel tests + gem.add_development_dependency 'activesupport' # for convenience in specs +end diff --git a/gems/slosilo/spec/encrypted_attributes_spec.rb b/gems/slosilo/spec/encrypted_attributes_spec.rb new file mode 100644 index 0000000000..d2828d9ce9 --- /dev/null +++ b/gems/slosilo/spec/encrypted_attributes_spec.rb @@ -0,0 +1,114 @@ +require 'spec_helper' +require 'slosilo/attr_encrypted' + +describe Slosilo::EncryptedAttributes do + before(:all) do + Slosilo::encryption_key = OpenSSL::Cipher.new("aes-256-gcm").random_key + end + + let(:aad) { proc{ |_| "hithere" } } + + let(:base){ + Class.new do + attr_accessor :normal_ivar,:with_aad + def stupid_ivar + side_effect! + @_explicit + end + def stupid_ivar= e + side_effect! + @_explicit = e + end + def side_effect! + + end + end + } + + let(:sub){ + Class.new(base) do + attr_encrypted :normal_ivar, :stupid_ivar + end + } + + subject{ sub.new } + + context "when setting a normal ivar" do + let(:value){ "some value" } + it "stores an encrypted value in the ivar" do + subject.normal_ivar = value + expect(subject.instance_variable_get(:"@normal_ivar")).to_not eq(value) + end + + it "recovers the value set" do + subject.normal_ivar = value + expect(subject.normal_ivar).to eq(value) + end + end + + context "when setting an attribute with an implementation" do + it "calls the base class method" do + expect(subject).to receive_messages(:side_effect! => nil) + subject.stupid_ivar = "hi" + expect(subject.stupid_ivar).to eq("hi") + end + end + + context "when given an :aad option" do + + let(:cipher){ Slosilo::EncryptedAttributes.cipher } + let(:key){ Slosilo::EncryptedAttributes.key} + context "that is a string" do + let(:aad){ "hello there" } + before{ sub.attr_encrypted :with_aad, aad: aad } + it "encrypts the value with the given string for auth data" do + expect(cipher).to receive(:encrypt).with("hello", key: key, aad: aad) + subject.with_aad = "hello" + end + + it "decrypts the encrypted value" do + subject.with_aad = "foo" + expect(subject.with_aad).to eq("foo") + end + end + + context "that is nil" do + let(:aad){ nil } + before{ sub.attr_encrypted :with_aad, aad: aad } + it "encrypts the value with an empty string for auth data" do + expect(cipher).to receive(:encrypt).with("hello",key: key, aad: "").and_call_original + subject.with_aad = "hello" + end + + it "decrypts the encrypted value" do + subject.with_aad = "hello" + expect(subject.with_aad).to eq("hello") + end + end + + context "that is a proc" do + let(:aad){ + proc{ |o| "x" } + } + + before{ sub.attr_encrypted :with_aad, aad: aad } + + it "calls the proc with the object being encrypted" do + expect(aad).to receive(:[]).with(subject).and_call_original + subject.with_aad = "hi" + end + + it "encrypts the value with the string returned for auth data" do + expect(cipher).to receive(:encrypt).with("hello", key: key, aad: aad[subject]).and_call_original + subject.with_aad = "hello" + end + it "decrypts the encrypted value" do + subject.with_aad = "hello" + expect(subject.with_aad).to eq("hello") + end + end + + end + + +end diff --git a/gems/slosilo/spec/file_adapter_spec.rb b/gems/slosilo/spec/file_adapter_spec.rb new file mode 100644 index 0000000000..6919efc6b5 --- /dev/null +++ b/gems/slosilo/spec/file_adapter_spec.rb @@ -0,0 +1,81 @@ +require 'spec_helper' +require 'tmpdir' + +require 'slosilo/adapters/file_adapter' + +describe Slosilo::Adapters::FileAdapter do + include_context "with example key" + + let(:dir) { Dir.mktmpdir } + let(:adapter) { Slosilo::Adapters::FileAdapter.new dir } + subject { adapter } + + describe "#get_key" do + context "when given key does not exist" do + it "returns nil" do + expect(subject.get_key(:whatever)).not_to be + end + end + end + + describe "#put_key" do + context "unacceptable id" do + let(:id) { "foo.bar" } + it "isn't accepted" do + expect { subject.put_key id, key }.to raise_error /id should not contain a period/ + end + end + context "acceptable id" do + let(:id) { "id" } + let(:key_encrypted) { "encrypted key" } + let(:fname) { "#{dir}/#{id}.key" } + it "creates the key" do + expect(Slosilo::EncryptedAttributes).to receive(:encrypt).with(key.to_der).and_return key_encrypted + expect(File).to receive(:write).with(fname, key_encrypted) + expect(File).to receive(:chmod).with(0400, fname) + subject.put_key id, key + expect(subject.instance_variable_get("@keys")[id]).to eq(key) + end + end + end + + describe "#each" do + before { adapter.instance_variable_set("@keys", one: :onek, two: :twok) } + + it "iterates over each key" do + results = [] + adapter.each { |id,k| results << { id => k } } + expect(results).to eq([ { one: :onek}, {two: :twok } ]) + end + end + + context 'with real key store' do + let(:id) { 'some id' } + + before do + Slosilo::encryption_key = Slosilo::Symmetric.new.random_key + pre_adapter = Slosilo::Adapters::FileAdapter.new dir + pre_adapter.put_key(id, key) + end + + describe '#get_key' do + it "loads and decrypts the key" do + expect(adapter.get_key(id)).to eq(key) + end + end + + describe '#get_by_fingerprint' do + it "can look up a key by a fingerprint" do + expect(adapter.get_by_fingerprint(key_fingerprint)).to eq([key, id]) + end + end + + describe '#each' do + it "enumerates the keys" do + results = [] + adapter.each { |id,k| results << { id => k } } + expect(results).to eq([ { id => key } ]) + end + end + end +end diff --git a/gems/slosilo/spec/jwt_spec.rb b/gems/slosilo/spec/jwt_spec.rb new file mode 100644 index 0000000000..eed3bfb233 --- /dev/null +++ b/gems/slosilo/spec/jwt_spec.rb @@ -0,0 +1,102 @@ +require 'spec_helper' + +# (Mostly) integration tests for JWT token format +describe Slosilo::Key do + include_context "with example key" + + describe '#issue_jwt' do + it 'issues an JWT token with given claims' do + allow(Time).to receive(:now) { DateTime.parse('2014-06-04 23:22:32 -0400').to_time } + + tok = key.issue_jwt sub: 'host/example', cidr: %w(fec0::/64) + + expect(tok).to be_frozen + + expect(tok.header).to eq \ + alg: 'conjur.org/slosilo/v2', + kid: key_fingerprint + expect(tok.claims).to eq \ + iat: 1401938552, + sub: 'host/example', + cidr: ['fec0::/64'] + + expect(key.verify_signature tok.string_to_sign, tok.signature).to be_truthy + end + end +end + +describe Slosilo::JWT do + context "with a signed token" do + let(:signature) { 'very signed, such alg' } + subject(:token) { Slosilo::JWT.new test: "token" } + before do + allow(Time).to receive(:now) { DateTime.parse('2014-06-04 23:22:32 -0400').to_time } + token.add_signature(alg: 'test-sig') { signature } + end + + it 'allows conversion to JSON representation with #to_json' do + json = JSON.load token.to_json + expect(JSON.load Base64.urlsafe_decode64 json['protected']).to eq \ + 'alg' => 'test-sig' + expect(JSON.load Base64.urlsafe_decode64 json['payload']).to eq \ + 'iat' => 1401938552, 'test' => 'token' + expect(Base64.urlsafe_decode64 json['signature']).to eq signature + end + + it 'allows conversion to compact representation with #to_s' do + h, c, s = token.to_s.split '.' + expect(JSON.load Base64.urlsafe_decode64 h).to eq \ + 'alg' => 'test-sig' + expect(JSON.load Base64.urlsafe_decode64 c).to eq \ + 'iat' => 1401938552, 'test' => 'token' + expect(Base64.urlsafe_decode64 s).to eq signature + end + end + + describe '#to_json' do + it "passes any parameters" do + token = Slosilo::JWT.new + allow(token).to receive_messages \ + header: :header, + claims: :claims, + signature: :signature + expect_any_instance_of(Hash).to receive(:to_json).with :testing + expect(token.to_json :testing) + end + end + + describe '()' do + include_context "with example key" + + it 'understands both serializations' do + [COMPACT_TOKEN, JSON_TOKEN].each do |token| + token = Slosilo::JWT token + expect(token.header).to eq \ + 'typ' => 'JWT', + 'alg' => 'conjur.org/slosilo/v2', + 'kid' => key_fingerprint + expect(token.claims).to eq \ + 'sub' => 'host/example', + 'iat' => 1401938552, + 'exp' => 1401938552 + 60*60, + 'cidr' => ['fec0::/64'] + expect(key.verify_signature token.string_to_sign, token.signature).to be_truthy + end + end + + it 'is a noop if already parsed' do + token = Slosilo::JWT COMPACT_TOKEN + expect(Slosilo::JWT token).to eq token + end + + it 'raises ArgumentError on failure to convert' do + expect { Slosilo::JWT "foo bar" }.to raise_error ArgumentError + expect { Slosilo::JWT elite: 31337 }.to raise_error ArgumentError + expect { Slosilo::JWT "foo.bar.xyzzy" }.to raise_error ArgumentError + end + end + + COMPACT_TOKEN = "eyJ0eXAiOiJKV1QiLCJhbGciOiJjb25qdXIub3JnL3Nsb3NpbG8vdjIiLCJraWQiOiIxMDdiZGI4NTAxYzQxOWZhZDJmZGIyMGI0NjdkNGQwYTYyYTE2YTk4YzM1ZjJkYTBlYjNiMWZmOTI5Nzk1YWQ5In0=.eyJzdWIiOiJob3N0L2V4YW1wbGUiLCJjaWRyIjpbImZlYzA6Oi82NCJdLCJleHAiOjE0MDE5NDIxNTIsImlhdCI6MTQwMTkzODU1Mn0=.qSxy6gx0DbiIc-Wz_vZhBsYi1SCkHhzxfMGPnnG6MTqjlzy7ntmlU2H92GKGoqCRo6AaNLA_C3hA42PeEarV5nMoTj8XJO_kwhrt2Db2OX4u83VS0_enoztWEZG5s45V0Lv71lVR530j4LD-hpqhm_f4VuISkeH84u0zX7s1zKOlniuZP-abCAHh0htTnrVz9wKG0VywkCUmWYyNNqC2h8PRf64SvCWcQ6VleHpjO-ms8OeTw4ZzRbzKMi0mL6eTmQlbT3PeBArUaS0pNJPg9zdDQaL2XDOofvQmj6Yy_8RA4eCt9HEfTYEdriVqK-_9QCspbGzFVn9GTWf51MRi5dngV9ItsDoG9ktDtqFuMttv7TcqjftsIHZXZsAZ175E".freeze + + JSON_TOKEN = "{\"protected\":\"eyJ0eXAiOiJKV1QiLCJhbGciOiJjb25qdXIub3JnL3Nsb3NpbG8vdjIiLCJraWQiOiIxMDdiZGI4NTAxYzQxOWZhZDJmZGIyMGI0NjdkNGQwYTYyYTE2YTk4YzM1ZjJkYTBlYjNiMWZmOTI5Nzk1YWQ5In0=\",\"payload\":\"eyJzdWIiOiJob3N0L2V4YW1wbGUiLCJjaWRyIjpbImZlYzA6Oi82NCJdLCJleHAiOjE0MDE5NDIxNTIsImlhdCI6MTQwMTkzODU1Mn0=\",\"signature\":\"qSxy6gx0DbiIc-Wz_vZhBsYi1SCkHhzxfMGPnnG6MTqjlzy7ntmlU2H92GKGoqCRo6AaNLA_C3hA42PeEarV5nMoTj8XJO_kwhrt2Db2OX4u83VS0_enoztWEZG5s45V0Lv71lVR530j4LD-hpqhm_f4VuISkeH84u0zX7s1zKOlniuZP-abCAHh0htTnrVz9wKG0VywkCUmWYyNNqC2h8PRf64SvCWcQ6VleHpjO-ms8OeTw4ZzRbzKMi0mL6eTmQlbT3PeBArUaS0pNJPg9zdDQaL2XDOofvQmj6Yy_8RA4eCt9HEfTYEdriVqK-_9QCspbGzFVn9GTWf51MRi5dngV9ItsDoG9ktDtqFuMttv7TcqjftsIHZXZsAZ175E\"}".freeze +end diff --git a/gems/slosilo/spec/key_spec.rb b/gems/slosilo/spec/key_spec.rb new file mode 100644 index 0000000000..5ed444fa60 --- /dev/null +++ b/gems/slosilo/spec/key_spec.rb @@ -0,0 +1,258 @@ +require 'spec_helper' + +require 'active_support' +require 'active_support/core_ext/numeric/time' + +describe Slosilo::Key do + include_context "with example key" + + subject { key } + + describe '#to_der' do + subject { super().to_der } + it { is_expected.to eq(rsa.to_der) } + end + + describe '#to_s' do + subject { super().to_s } + it { is_expected.to eq(rsa.public_key.to_pem) } + end + + describe '#fingerprint' do + subject { super().fingerprint } + it { is_expected.to eq(key_fingerprint) } + end + it { is_expected.to be_private } + + context "with identical key" do + let(:other) { Slosilo::Key.new rsa.to_der } + it "is equal" do + expect(subject).to eq(other) + end + + it "is eql?" do + expect(subject.eql?(other)).to be_truthy + end + + it "has equal hash" do + expect(subject.hash).to eq(other.hash) + end + end + + context "with a different key" do + let(:other) { Slosilo::Key.new another_rsa } + it "is not equal" do + expect(subject).not_to eq(other) + end + + it "is not eql?" do + expect(subject.eql?(other)).not_to be_truthy + end + + it "has different hash" do + expect(subject.hash).not_to eq(other.hash) + end + end + + describe '#public' do + it "returns a key with just the public half" do + pkey = subject.public + expect(pkey).to be_a(Slosilo::Key) + expect(pkey).to_not be_private + expect(pkey.key).to_not be_private + expect(pkey.to_der).to eq(rsa.public_key.to_der) + end + end + + let(:plaintext) { 'quick brown fox jumped over the lazy dog' } + describe '#encrypt' do + it "generates a symmetric encryption key and encrypts the plaintext with the public key" do + ctxt, skey = subject.encrypt plaintext + pskey = rsa.private_decrypt skey + expect(Slosilo::Symmetric.new.decrypt(ctxt, key: pskey)).to eq(plaintext) + end + end + + describe '#encrypt_message' do + it "#encrypts a message and then returns the result as a single string" do + expect(subject).to receive(:encrypt).with(plaintext).and_return ['fake ciphertext', 'fake key'] + expect(subject.encrypt_message(plaintext)).to eq('fake keyfake ciphertext') + end + end + + let(:ciphertext){ "G\xAD^\x17\x11\xBBQ9-b\x14\xF6\x92#Q0x\xF4\xAD\x1A\x92\xC3VZW\x89\x8E\x8Fg\x93\x05B\xF8\xD6O\xCFGCTp\b~\x916\xA3\x9AN\x8D\x961\x1F\xA3mSf&\xAD\xA77/]z\xA89\x01\xA7\xA9\x92\f".force_encoding('ASCII-8BIT') } + let(:skey){ "\x82\x93\xFAA\xA6wQA\xE1\xB5\xA6b\x8C.\xCF#I\x86I\x83u\x99\rTA\xEF\xC4\x91\xC5)-\xEBQ\xB1\xC0\xC6\xFF\x90L\xFE\x1E\x15\x81\x12\x16\xDD:A\xC5d\xE1B\xD2f@\xB8o\xB7+N\xB7\n\x92\xDC\x9E\xE3\x83\xB8>h\a\xC7\xCC\xCF\xD0t\x06\x8B\xA8\xBF\xEFe\xA4{\x88\f\xDD\roF\xEB.\xDA\xBF\x9D_0>\xF03c'\x1F!)*-\x19\x97\xAC\xD2\x1F(,6h\a\x93\xDB\x8E\x97\xF9\x1A\x11\x84\x11t\xD9\xB2\x85\xB0\x12\x7F\x03\x00O\x8F\xBE#\xFFb\xA5w\xF3g\xCF\xB4\xF2\xB7\xDBiA=\xA8\xFD1\xEC\xBF\xD7\x8E\xB6W>\x03\xACNBa\xBF\xFD\xC6\xB32\x8C\xE2\xF1\x87\x9C\xAE6\xD1\x12\vkl\xBB\xA0\xED\x9A\xEE6\xF2\xD9\xB4LL\xE2h/u_\xA1i=\x11x\x8DGha\x8EG\b+\x84[\x87\x8E\x01\x0E\xA5\xB0\x9F\xE9vSl\x18\xF3\xEA\xF4NH\xA8\xF1\x81\xBB\x98\x01\xE8p]\x18\x11f\xA3K\xA87c\xBB\x13X~K\xA2".force_encoding('ASCII-8BIT') } + describe '#decrypt' do + it "decrypts the symmetric key and then uses it to decrypt the ciphertext" do + expect(subject.decrypt(ciphertext, skey)).to eq(plaintext) + end + end + + describe '#decrypt_message' do + it "splits the message into key and rest, then #decrypts it" do + expect(subject).to receive(:decrypt).with(ciphertext, skey).and_return plaintext + expect(subject.decrypt_message(skey + ciphertext)).to eq(plaintext) + end + end + + describe '#initialize' do + context "when no argument given" do + subject { Slosilo::Key.new } + let (:rsa) { double "key" } + it "generates a new key pair" do + expect(OpenSSL::PKey::RSA).to receive(:new).with(2048).and_return(rsa) + expect(subject.key).to eq(rsa) + end + end + context "when given an armored key" do + subject { Slosilo::Key.new rsa.to_der } + + describe '#to_der' do + subject { super().to_der } + it { is_expected.to eq(rsa.to_der) } + end + end + context "when given a key instance" do + subject { Slosilo::Key.new rsa } + + describe '#to_der' do + subject { super().to_der } + it { is_expected.to eq(rsa.to_der) } + end + end + context "when given something else" do + subject { Slosilo::Key.new "foo" } + it "fails early" do + expect { subject }.to raise_error ArgumentError + end + end + end + + describe "#sign" do + context "when given a hash" do + it "converts to a sorted array and signs that" do + expect(key).to receive(:sign_string).with '[["a",3],["b",42]]' + key.sign b: 42, a: 3 + end + end + context "when given an array" do + it "signs a JSON representation instead" do + expect(key).to receive(:sign_string).with '[2,[42,2]]' + key.sign [2, [42, 2]] + end + end + context "when given a string" do + let(:expected_signature) { "d[\xA4\x00\x02\xC5\x17\xF5P\x1AD\x91\xF9\xC1\x00P\x0EG\x14,IN\xDE\x17\xE1\xA2a\xCC\xABR\x99'\xB0A\xF5~\x93M/\x95-B\xB1\xB6\x92!\x1E\xEA\x9C\v\xC2O\xA8\x91\x1C\xF9\x11\x92a\xBFxm-\x93\x9C\xBBoM\x92%\xA9\xD06$\xC1\xBC.`\xF8\x03J\x16\xE1\xB0c\xDD\xBF\xB0\xAA\xD7\xD4\xF4\xFC\e*\xAB\x13A%-\xD3\t\xA5R\x18\x01let6\xC8\xE9\"\x7F6O\xC7p\x82\xAB\x04J(IY\xAA]b\xA4'\xD6\x873`\xAB\x13\x95g\x9C\x17\xCAB\xF8\xB9\x85B:^\xC5XY^\x03\xEA\xB6V\x17b2\xCA\xF5\xD6\xD4\xD2\xE3u\x11\xECQ\x0Fb\x14\xE2\x04\xE1 unicode} } + + it "converts the value to raw bytes before signing it" do + expect(key).to receive(:sign_string).with("[[\"data\",\"#{encoded}\"]]").and_call_original + key.sign hash + end + end + end + + describe "#signed_token" do + let(:time) { Time.new(2012,1,1,1,1,1,0) } + let(:data) { { "foo" => :bar } } + let(:token_to_sign) { { "data" => data, "timestamp" => "2012-01-01 01:01:01 UTC" } } + let(:signature) { "signature" } + let(:salt) { 'a pinch of salt' } + let(:expected_signature) { Base64::urlsafe_encode64 "\xB0\xCE{\x9FP\xEDV\x9C\xE7b\x8B[\xFAil\x87^\x96\x17Z\x97\x1D\xC2?B\x96\x9C\x8Ep-\xDF_\x8F\xC21\xD9^\xBC\n\x16\x04\x8DJ\xF6\xAF-\xEC\xAD\x03\xF9\xEE:\xDF\xB5\x8F\xF9\xF6\x81m\xAB\x9C\xAB1\x1E\x837\x8C\xFB\xA8P\xA8<\xEA\x1Dx\xCEd\xED\x84f\xA7\xB5t`\x96\xCC\x0F\xA9t\x8B\x9Fo\xBF\x92K\xFA\xFD\xC5?\x8F\xC68t\xBC\x9F\xDE\n$\xCA\xD2\x8F\x96\x0EtX2\x8Cl\x1E\x8Aa\r\x8D\xCAi\x86\x1A\xBD\x1D\xF7\xBC\x8561j\x91YlO\xFA(\x98\x10iq\xCC\xAF\x9BV\xC6\v\xBC\x10Xm\xCD\xFE\xAD=\xAA\x95,\xB4\xF7\xE8W\xB8\x83;\x81\x88\xE6\x01\xBA\xA5F\x91\x17\f\xCE\x80\x8E\v\x83\x9D<\x0E\x83\xF6\x8D\x03\xC0\xE8A\xD7\x90i\x1D\x030VA\x906D\x10\xA0\xDE\x12\xEF\x06M\xD8\x8B\xA9W\xC8\x9DTc\x8AJ\xA4\xC0\xD3!\xFA\x14\x89\xD1p\xB4J7\xA5\x04\xC2l\xDC8<\x04Y\xD8\xA4\xFB[\x89\xB1\xEC\xDA\xB8\xD7\xEA\x03Ja pinch of salt".force_encoding("ASCII-8BIT") } + let(:expected_token) { token_to_sign.merge "signature" => expected_signature, "key" => key_fingerprint } + before do + allow(key).to receive_messages shake_salt: salt + allow(Time).to receive_messages new: time + end + subject { key.signed_token data } + it { is_expected.to eq(expected_token) } + end + + describe "#validate_jwt" do + let(:token) do + instance_double Slosilo::JWT, + header: { 'alg' => 'conjur.org/slosilo/v2' }, + claims: { 'iat' => Time.now.to_i }, + string_to_sign: double("string to sign"), + signature: double("signature") + end + + before do + allow(key).to receive(:verify_signature).with(token.string_to_sign, token.signature) { true } + end + + it "verifies the signature" do + expect { key.validate_jwt token }.not_to raise_error + end + + it "rejects unknown algorithm" do + token.header['alg'] = 'HS256' # we're not supporting standard algorithms + expect { key.validate_jwt token }.to raise_error /algorithm/ + end + + it "rejects bad signature" do + allow(key).to receive(:verify_signature).with(token.string_to_sign, token.signature) { false } + expect { key.validate_jwt token }.to raise_error /signature/ + end + + it "rejects expired token" do + token.claims['exp'] = 1.hour.ago.to_i + expect { key.validate_jwt token }.to raise_error /expired/ + end + + it "accepts unexpired token with implicit expiration" do + token.claims['iat'] = 5.minutes.ago + expect { key.validate_jwt token }.to_not raise_error + end + + it "rejects token expired with implicit expiration" do + token.claims['iat'] = 10.minutes.ago.to_i + expect { key.validate_jwt token }.to raise_error /expired/ + end + end + + describe "#token_valid?" do + let(:data) { { "foo" => :bar } } + let(:signature) { Base64::urlsafe_encode64 "\xB0\xCE{\x9FP\xEDV\x9C\xE7b\x8B[\xFAil\x87^\x96\x17Z\x97\x1D\xC2?B\x96\x9C\x8Ep-\xDF_\x8F\xC21\xD9^\xBC\n\x16\x04\x8DJ\xF6\xAF-\xEC\xAD\x03\xF9\xEE:\xDF\xB5\x8F\xF9\xF6\x81m\xAB\x9C\xAB1\x1E\x837\x8C\xFB\xA8P\xA8<\xEA\x1Dx\xCEd\xED\x84f\xA7\xB5t`\x96\xCC\x0F\xA9t\x8B\x9Fo\xBF\x92K\xFA\xFD\xC5?\x8F\xC68t\xBC\x9F\xDE\n$\xCA\xD2\x8F\x96\x0EtX2\x8Cl\x1E\x8Aa\r\x8D\xCAi\x86\x1A\xBD\x1D\xF7\xBC\x8561j\x91YlO\xFA(\x98\x10iq\xCC\xAF\x9BV\xC6\v\xBC\x10Xm\xCD\xFE\xAD=\xAA\x95,\xB4\xF7\xE8W\xB8\x83;\x81\x88\xE6\x01\xBA\xA5F\x91\x17\f\xCE\x80\x8E\v\x83\x9D<\x0E\x83\xF6\x8D\x03\xC0\xE8A\xD7\x90i\x1D\x030VA\x906D\x10\xA0\xDE\x12\xEF\x06M\xD8\x8B\xA9W\xC8\x9DTc\x8AJ\xA4\xC0\xD3!\xFA\x14\x89\xD1p\xB4J7\xA5\x04\xC2l\xDC8<\x04Y\xD8\xA4\xFB[\x89\xB1\xEC\xDA\xB8\xD7\xEA\x03Ja pinch of salt".force_encoding("ASCII-8BIT") } + let(:token) { { "data" => data, "timestamp" => "2012-01-01 01:01:01 UTC", "signature" => signature } } + before { allow(Time).to receive_messages now: Time.new(2012,1,1,1,2,1,0) } + subject { key.token_valid? token } + it { is_expected.to be_truthy } + + it "doesn't check signature on the advisory key field" do + expect(key.token_valid?(token.merge "key" => key_fingerprint)).to be_truthy + end + + it "rejects the token if the key field is present and doesn't match" do + expect(key.token_valid?(token.merge "key" => "this is not the key you are looking for")).not_to be_truthy + end + + context "when token is 1 hour old" do + before { allow(Time).to receive_messages now: Time.new(2012,1,1,2,1,1,0) } + it { is_expected.to be_falsey } + context "when timestamp in the token is changed accordingly" do + let(:token) { { "data" => data, "timestamp" => "2012-01-01 02:00:01 UTC", "signature" => signature } } + it { is_expected.to be_falsey } + end + end + context "when the data is changed" do + let(:data) { { "foo" => :baz } } + it { is_expected.to be_falsey } + end + context "when RSA decrypt raises an error" do + before { expect_any_instance_of(OpenSSL::PKey::RSA).to receive(:public_decrypt).and_raise(OpenSSL::PKey::RSAError) } + it { is_expected.to be_falsey } + end + end +end diff --git a/gems/slosilo/spec/keystore_spec.rb b/gems/slosilo/spec/keystore_spec.rb new file mode 100644 index 0000000000..d11db76b8f --- /dev/null +++ b/gems/slosilo/spec/keystore_spec.rb @@ -0,0 +1,26 @@ +require 'spec_helper' + +describe Slosilo::Keystore do + include_context "with example key" + include_context "with mock adapter" + + describe '#put' do + it "handles Slosilo::Keys" do + subject.put(:test, key) + expect(adapter['test'].to_der).to eq(rsa.to_der) + end + + it "refuses to store a key with a nil id" do + expect { subject.put(nil, key) }.to raise_error(ArgumentError) + end + + it "refuses to store a key with an empty id" do + expect { subject.put('', key) }.to raise_error(ArgumentError) + end + + it "passes the Slosilo key to the adapter" do + expect(adapter).to receive(:put_key).with "test", key + subject.put :test, key + end + end +end diff --git a/gems/slosilo/spec/random_spec.rb b/gems/slosilo/spec/random_spec.rb new file mode 100644 index 0000000000..12c3184775 --- /dev/null +++ b/gems/slosilo/spec/random_spec.rb @@ -0,0 +1,19 @@ +require 'spec_helper' + +describe Slosilo::Random do + subject { Slosilo::Random } + let(:other_salt) { Slosilo::Random::salt } + + describe '#salt' do + subject { super().salt } + describe '#length' do + subject { super().length } + it { is_expected.to eq(32) } + end + end + + describe '#salt' do + subject { super().salt } + it { is_expected.not_to eq(other_salt) } + end +end diff --git a/gems/slosilo/spec/sequel_adapter_spec.rb b/gems/slosilo/spec/sequel_adapter_spec.rb new file mode 100644 index 0000000000..6625f79168 --- /dev/null +++ b/gems/slosilo/spec/sequel_adapter_spec.rb @@ -0,0 +1,182 @@ +require 'spec_helper' +require 'sequel' +require 'io/grab' + +require 'slosilo/adapters/sequel_adapter' + +describe Slosilo::Adapters::SequelAdapter do + include_context "with example key" + + let(:model) { double "model" } + before { allow(subject).to receive_messages create_model: model } + + describe "#get_key" do + context "when given key does not exist" do + before { allow(model).to receive_messages :[] => nil } + it "returns nil" do + expect(subject.get_key(:whatever)).not_to be + end + end + + context "when it exists" do + let(:id) { "id" } + before { allow(model).to receive(:[]).with(id).and_return (double "key entry", id: id, key: rsa.to_der) } + it "returns it" do + expect(subject.get_key(id)).to eq(key) + end + end + end + + describe "#put_key" do + let(:id) { "id" } + it "creates the key" do + expect(model).to receive(:create).with(hash_including(:id => id, :key => key.to_der)) + expect(model).to receive(:[]).with(id).and_return(nil) + allow(model).to receive_messages columns: [:id, :key] + subject.put_key id, key + end + + it "adds the fingerprint if feasible" do + expect(model).to receive(:create).with(hash_including(:id => id, :key => key.to_der, :fingerprint => key.fingerprint)) + expect(model).to receive(:[]).with(id).and_return(nil) + allow(model).to receive_messages columns: [:id, :key, :fingerprint] + subject.put_key id, key + end + + it "update existing key" do + hash = hash_including(:id => id, :key => another_key.to_der) + expect(model).to receive(:[]).with(id).and_return(hash) + expect(hash).to receive(:update).with(hash) + allow(model).to receive_messages columns: [:id, :another_key] + subject.put_key id, key + end + + end + + let(:adapter) { subject } + describe "#each" do + let(:one) { double("one", id: :one, key: :onek) } + let(:two) { double("two", id: :two, key: :twok) } + before { allow(model).to receive(:each).and_yield(one).and_yield(two) } + + it "iterates over each key" do + results = [] + allow(Slosilo::Key).to receive(:new) {|x|x} + adapter.each { |id,k| results << { id => k } } + expect(results).to eq([ { one: :onek}, {two: :twok } ]) + end + end + + shared_context "database" do + let(:db) { Sequel.sqlite } + before do + allow(subject).to receive(:create_model).and_call_original + Sequel::Model.cache_anonymous_models = false + Sequel::Model.db = db + end + end + + shared_context "encryption key" do + before do + Slosilo.encryption_key = Slosilo::Symmetric.new.random_key + end + end + + context "with old schema" do + include_context "encryption key" + include_context "database" + + before do + db.create_table :slosilo_keystore do + String :id, primary_key: true + bytea :key, null: false + end + subject.put_key 'test', key + end + + context "after migration" do + before { subject.migrate! } + + it "supports look up by id" do + expect(subject.get_key("test")).to eq(key) + end + + it "supports look up by fingerprint, without a warning" do + expect($stderr.grab do + expect(subject.get_by_fingerprint(key.fingerprint)).to eq([key, 'test']) + end).to be_empty + end + end + + it "supports look up by id" do + expect(subject.get_key("test")).to eq(key) + end + + it "supports look up by fingerprint, but issues a warning" do + expect($stderr.grab do + expect(subject.get_by_fingerprint(key.fingerprint)).to eq([key, 'test']) + end).not_to be_empty + end + end + + shared_context "current schema" do + include_context "database" + before do + Sequel.extension :migration + require 'slosilo/adapters/sequel_adapter/migration.rb' + Sequel::Migration.descendants.first.apply db, :up + end + end + + context "with current schema" do + include_context "encryption key" + include_context "current schema" + before do + subject.put_key 'test', key + end + + it "supports look up by id" do + expect(subject.get_key("test")).to eq(key) + end + + it "supports look up by fingerprint" do + expect(subject.get_by_fingerprint(key.fingerprint)).to eq([key, 'test']) + end + end + + context "with an encryption key", :wip do + include_context "encryption key" + include_context "current schema" + + it { is_expected.to be_secure } + + it "saves the keys in encrypted form" do + subject.put_key 'test', key + + expect(db[:slosilo_keystore][id: 'test'][:key]).to_not eq(key.to_der) + expect(subject.get_key 'test').to eq(key) + end + end + + context "without an encryption key", :wip do + before do + Slosilo.encryption_key = nil + end + + include_context "current schema" + + it { is_expected.not_to be_secure } + + it "refuses to store a private key" do + expect { subject.put_key 'test', key }.to raise_error(Slosilo::Error::InsecureKeyStorage) + end + + it "saves the keys in plaintext form" do + pkey = key.public + subject.put_key 'test', pkey + + expect(db[:slosilo_keystore][id: 'test'][:key]).to eq(pkey.to_der) + expect(subject.get_key 'test').to eq(pkey) + end + end +end diff --git a/gems/slosilo/spec/slosilo_spec.rb b/gems/slosilo/spec/slosilo_spec.rb new file mode 100644 index 0000000000..38ed63f371 --- /dev/null +++ b/gems/slosilo/spec/slosilo_spec.rb @@ -0,0 +1,124 @@ +require 'spec_helper' + +describe Slosilo do + include_context "with mock adapter" + include_context "with example key" + before { Slosilo['test'] = key } + + describe '[]' do + it "returns a Slosilo::Key" do + expect(Slosilo[:test]).to be_instance_of Slosilo::Key + end + + it "allows looking up by fingerprint" do + expect(Slosilo[fingerprint: key_fingerprint]).to eq(key) + end + + context "when the requested key does not exist" do + it "returns nil instead of creating a new key" do + expect(Slosilo[:aether]).not_to be + end + end + end + + describe '.sign' do + let(:own_key) { double "own key" } + before { allow(Slosilo).to receive(:[]).with(:own).and_return own_key } + let (:argument) { double "thing to sign" } + it "fetches the own key and signs using that" do + expect(own_key).to receive(:sign).with(argument) + Slosilo.sign argument + end + end + + describe '.token_valid?' do + before { allow(adapter['test']).to receive_messages token_valid?: false } + let(:key2) { double "key 2", token_valid?: false } + let(:key3) { double "key 3", token_valid?: false } + before do + adapter[:key2] = key2 + adapter[:key3] = key3 + end + + let(:token) { double "token" } + subject { Slosilo.token_valid? token } + + context "when no key validates the token" do + before { allow(Slosilo::Key).to receive_messages new: (double "key", token_valid?: false) } + it { is_expected.to be_falsey } + end + + context "when a key validates the token" do + let(:valid_key) { double token_valid?: true } + let(:invalid_key) { double token_valid?: true } + before do + allow(Slosilo::Key).to receive_messages new: invalid_key + adapter[:key2] = valid_key + end + + it { is_expected.to be_truthy } + end + end + + describe '.token_signer' do + + context "when token matches a key" do + let(:token) {{ 'data' => 'foo', 'key' => key.fingerprint, 'signature' => 'XXX' }} + + context "and the signature is valid" do + before { allow(key).to receive(:token_valid?).with(token).and_return true } + + it "returns the key id" do + expect(subject.token_signer(token)).to eq('test') + end + end + + context "and the signature is invalid" do + before { allow(key).to receive(:token_valid?).with(token).and_return false } + + it "returns nil" do + expect(subject.token_signer(token)).not_to be + end + end + end + + context "when token doesn't match a key" do + let(:token) {{ 'data' => 'foo', 'key' => "footprint", 'signature' => 'XXX' }} + it "returns nil" do + expect(subject.token_signer(token)).not_to be + end + end + + context "with JWT token" do + before do + expect(key).to receive(:validate_jwt) do |jwt| + expect(jwt.header).to eq 'kid' => key.fingerprint + expect(jwt.claims).to eq({}) + expect(jwt.signature).to eq 'sig' + end + end + + it "accepts pre-parsed JSON serialization" do + expect(Slosilo.token_signer( + 'protected' => 'eyJraWQiOiIxMDdiZGI4NTAxYzQxOWZhZDJmZGIyMGI0NjdkNGQwYTYyYTE2YTk4YzM1ZjJkYTBlYjNiMWZmOTI5Nzk1YWQ5In0=', + 'payload' => 'e30=', + 'signature' => 'c2ln' + )).to eq 'test' + end + + it "accepts pre-parsed JWT token" do + expect(Slosilo.token_signer(Slosilo::JWT( + 'protected' => 'eyJraWQiOiIxMDdiZGI4NTAxYzQxOWZhZDJmZGIyMGI0NjdkNGQwYTYyYTE2YTk4YzM1ZjJkYTBlYjNiMWZmOTI5Nzk1YWQ5In0=', + 'payload' => 'e30=', + 'signature' => 'c2ln' + ))).to eq 'test' + end + + it "accepts compact serialization" do + expect(Slosilo.token_signer( + 'eyJraWQiOiIxMDdiZGI4NTAxYzQxOWZhZDJmZGIyMGI0NjdkNGQwYTYyYTE2YTk4YzM1ZjJkYTBlYjNiMWZmOTI5Nzk1YWQ5In0=.e30=.c2ln' + )).to eq 'test' + end + end + end +end diff --git a/gems/slosilo/spec/spec_helper.rb b/gems/slosilo/spec/spec_helper.rb new file mode 100644 index 0000000000..0530f63f59 --- /dev/null +++ b/gems/slosilo/spec/spec_helper.rb @@ -0,0 +1,84 @@ +require "simplecov" +require "simplecov-cobertura" + +SimpleCov.formatter = SimpleCov::Formatter::CoberturaFormatter +SimpleCov.start + +require 'slosilo' + +shared_context "with mock adapter" do + require 'slosilo/adapters/mock_adapter' + + let(:adapter) { Slosilo::Adapters::MockAdapter.new } + before { Slosilo::adapter = adapter } +end + +shared_context "with example key" do + let(:rsa) { OpenSSL::PKey::RSA.new """ +-----BEGIN RSA PRIVATE KEY----- +MIIEpQIBAAKCAQEAtTG/SQhW9QawP+GL6EZ5Al9gscCr7HiRO7MuQqFkaXIJD6+3 +prdHRrb0qqjNlGFgDBGAuswZ2AYqhBt7eekup+/vIpI5n04b0w+is3WwZAFco4uP +ojDeM0aY65Ar3Zgra2vWUJXRwBumroZjVBVoLJSgVfwIhwU6ORbS2oJflbtqxpuS +zkPDqS6RwEzI/DHuHTOI26fe+vfuDqGOuSR6iVI16lfvTbWwccpDwU0W9vSlyjjD +LIw0MnoKL3DHyzO66s+oNNRleMvjghQtJk/xg1kRuHReJ5/ygt2zyzdKSLeqU+T+ +TCWw/F65jrFElftexiS+g+lZC467VLCaMe1fJQIDAQABAoIBAQCiNWzXRr4CEQDL +z3Deeehu9U+tEZ1Xzv/FgD0TrUQlGc9+2YIBn+YRKkySUxfnk9zWMP0bPQiN2cdK +CQhbNSNteGCOhHVNZjGGm2K+YceNX6K9Tn1BZ5okMTlI+QIsGMQWIK316omh/58S +coCNj7R45H09PKmtpkJfRU1yDHDhqypjPDpb9/7U5mt3g2BdXYi+1hilfonHoDrC +yy3eRdf7Tlij9O3UeM+Z7pZrKATcvpDkYbNWizDITvKMYy6Ss+ajM5v7lt6QN5LP +MHjwX8Ilrxkxl0jeopr4f94tR7rNDZbLC457j8gns7cUeODtF7pPZqlrlk4KOq8Q +DvEMt2ZpAoGBAOLNUiO1SwRo75Y8ukuMVQev8O8WuzEEGINoM1lQiYlbUw3HmVp3 +iUvv58ANmKSzTXpOEZ1L8pZHSp435FrzD2WZmCAoXhNdfAXtmZA7Y46iE6BF4qrr +UegtLPhVgwpO74Y+4w2YwfDknzCOhWE4sxCbukuSvxz2pz1Vm31eFB6jAoGBAMyF +VxfYq9WhmLNsHqR+qfhb1EC5FfpSq23z/o4ryiKqCaWHimVtOO7DL7x2SK3mVNNZ +X8b4+vnJpAQ3nOxeg8fpmBaLAWYRna2AN/CYVIMKYusawhsGAlZZTu2mtJKLiOPS +8/z5dK55xJWlG5JalUB+n/4vd3WmXiT/XJj3qU+XAoGBALyHzLXeKCPcTvzmMj5G +wxAG0xMMJEMUkoP5hGXEKvBBOAMGXpXzM/Ap1s2w/6g5XDhE2SOWVGtTi9WFxI9N +6Qid6vUgWUNjvIr4/WQF2jZgyEu8jDVkM8v6cZ1lB+7zuuwvLnLI/r6ObT3h20H7 +7e3qZawYqkEbT94OYZiPMc5dAoGAHmIQtjIyFOKU1NLTGozWo1bBCXx1j2KIpSUC +RAytUsj/9d9U6Ax50L6ecNkBoxP8tgko+V4zqrgR7a51WYgQ+7nwJikwZAFp80SB +CvUWWQFKALNQ8sLJxhouZ4/Ec6DXDUFhjcthUio00iZdGjjqw1IMYq6aiJfWlJh7 +IR5pwLECgYEAyjlguks/3cjrHRF+yjonxT4tLuBI/n3TAQUPqmtkJtcwZSAJas/1 +c/twlAJ7F6n3ZroF3lgPxMJRRHZl4Z4dJsDatIxVShf3nSGg9Mi5C25obxahbv5/ +Dg1ikwi8GUF4HPZe9DyhXgDhg19wM/qcpjX8bSypsUWHWP+FanhjdWU= +-----END RSA PRIVATE KEY----- + """ } + let (:key) { Slosilo::Key.new rsa.to_der } + let (:key_fingerprint) { "107bdb8501c419fad2fdb20b467d4d0a62a16a98c35f2da0eb3b1ff929795ad9" } + + let (:another_rsa) do + OpenSSL::PKey::RSA.new """ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAryP0uGEIcDFmHDj1MjxbW+eWMeQ1k2FTKI7qx2M3MP9FR3Bz +KjFzGKnAA6QV46K/QtEt+wpWedB/bcikPXY4/vh/b2TEi8Ybw2ztT1oW9le8Djsz +3sQv5QrHsOXzSIARw4NZYxunxMFKCVC9jA8tXJb16RLgS3wAOMiPADlWIKEmPIX6 ++hg2PDgFcrCuL3XAwJ4GKy3Q5BpIFF2j+wRNfjCXDFf1bU9Gy9DND8Y50Khhw/Zn +GYN1Y3AZ3YPzz1SPf08WM663ImYwORjdkA5VlIAMKcmSStNZZUrCOo7DQjNZVD2O +vfGhGUlPqYkmTPnCG2aNP8aJm3IbF+Cb6N6PjwIDAQABAoIBAEaYtr9PlagrsV40 +81kxjR3pptgrhhEHTQ7vNOH0Mz4T16gpQrLCRgOuARE2pgAhDPlw+hjUHPFzQrpN +Ay8nJWhZYHzVYIh67ZwDn1C6HsFjshEGei0UZb3sb3v15O/Xd9GYc4KIlkKwKxjA +K/d18rH8w9kUW8bxj+FTrpjHg9kYkWGjl1WUM4o4dALVVAbbILCHKUIv3wmU5Off +oqBDunItrfVvvc9UOt1SMO15fwuZZpk0B5cjjo6+1NNpIOzqnuu48iI5dQRAIr50 +n44U4/Ix4E1p4i/9i5trCeSZRMrVxBruNxFBtCeDU6YW5fXYNBLptndfb83iqSJf +46myqakCgYEA2MAsbtOcvQv+C7KsRMQih4WqpybV/TRdeC+dZ3flPvSuI8VLJAHp +p2Tp3WXATCwgUWL/iktwWE7WFMn3VvAuMm2ITmAze/Uk71uUS5R+iaGIeRXHgd9J +fyJrIeD63ncWbb23rif2sO6zH4cp9NLS/OopHiRNlRsWEUoGpybxczMCgYEAztrf +mX4oqjqk4af4o4/UHVp3Y9lpcUXRi6dYYECoqv6wS7qCIbJkD4I4P6oTwvk25vbk +p9fwOttuqHC53/rDXVjedNe9VExIe5NhVaug1SyArw/qsafYs0QeDRBkSgCcLfP6 +LP4g824Wbv52X33BO0rJbDCICDqGDCOkqB4XcjUCgYBCkcMTxqo85ZIAxb9i31o7 +hTIEZEkUmyCZ6QXO4WPnEf7pvY52YKACaVvqQ3Xr7yF93YneT40RkiTt/ZmZeeq2 +Ui2q5KDrUT8mxFmnXNQAMTxY8/dyS8Gm6ks8/HwQF0MsMThYpK1/adBZvomER7vF +MaWvPDcXtFnytWmVrMA7QQKBgQDIHpHR4m6e+atIMIPoYR5Z44q7i7tp/ZzTGevy ++rry6wFN0jtRNE9/fYDDftwtdYL7AYKHKu7bUi0FQkFhAi39YhudOJaPNlmtTBEP +m8I2Wh6IvsJUa0jHbbAQ/Xm46kwuXOn8m0LvnuKPMRj+GyBVJ24kf/Mq2suSdO04 +RBx0vQKBgFz93G6bSzmFg0BRTqRWEXEIuYkMIZDe48OjeP4pLYH9aERsL/f/8Dyc +X2nOMv/TdLP7mvGnwCt/sQ2626DdiNqimekyBki9J2r6BzBNVmEvnLAcYaQAiQYz +ooQ2FuL0K6ukQfHPjuMswqi41lmVH8gIVqVC+QnImUCrGxH9WXWy +-----END RSA PRIVATE KEY----- + """ + end + + def self.mock_own_key + before { allow(Slosilo).to receive(:[]).with(:own).and_return key } + end +end diff --git a/gems/slosilo/spec/symmetric_spec.rb b/gems/slosilo/spec/symmetric_spec.rb new file mode 100644 index 0000000000..c3d46862ca --- /dev/null +++ b/gems/slosilo/spec/symmetric_spec.rb @@ -0,0 +1,94 @@ +require 'spec_helper' + +describe Slosilo::Symmetric do + # TODO transform it to class methods only? + let(:plaintext) { "quick brown fox jumped over the lazy dog" } + let(:auth_data) { "some record id" } + let(:key) { "^\xBAIv\xDB1\x0Fi\x04\x11\xFD\x14\xA7\xCD\xDFf\x93\xFE\x93}\v\x01\x11\x98\x14\xE0;\xC1\xE2 v\xA5".force_encoding("ASCII-8BIT") } + let(:iv) { "\xD9\xABn\x01b\xFA\xBD\xC2\xE5\xEA\x01\xAC".force_encoding("ASCII-8BIT") } + let(:ciphertext) { "G^W1\x9C\xD4\xCC\x87\xD3\xFF\x86[\x0E3\xC0\xC8^\xD9\xABn\x01b\xFA\xBD\xC2\xE5\xEA\x01\xAC\x9E\xB9:\xF7\xD4ebeq\xDC \xC0sG\xA4\xAE,\xB8A|\x97\xBC\xFD\x85\xE1\xB93\x95>\xBD\n\x05\xFB\x15\x1F\x06#3M9".force_encoding('ASCII-8BIT') } + + describe '#encrypt' do + it "encrypts with AES-256-GCM" do + allow(subject).to receive_messages random_iv: iv + expect(subject.encrypt(plaintext, key: key, aad: auth_data)).to eq(ciphertext) + end + end + + describe '#decrypt' do + + it "doesn't fail when called by multiple threads" do + threads = [] + + begin + # Verify we can successfuly decrypt using many threads without OpenSSL + # errors. + 1000.times do + threads << Thread.new do + 100.times do + expect( + subject.decrypt(ciphertext, key: key, aad: auth_data) + ).to eq(plaintext) + end + end + end + ensure + threads.each(&:join) + end + end + + it "decrypts with AES-256-GCM" do + expect(subject.decrypt(ciphertext, key: key, aad: auth_data)).to eq(plaintext) + end + + + context "when the ciphertext has been messed with" do + let(:ciphertext) { "pwnd!" } # maybe we should do something more realistic like add some padding? + it "raises an exception" do + expect{ subject.decrypt(ciphertext, key: key, aad: auth_data)}.to raise_exception /Invalid version/ + end + context "by adding a trailing 0" do + let(:new_ciphertext){ ciphertext + '\0' } + it "raises an exception" do + expect{ subject.decrypt(new_ciphertext, key: key, aad: auth_data) }.to raise_exception /Invalid version/ + end + end + end + + context "when no auth_data is given" do + let(:auth_data){""} + let(:ciphertext){ "Gm\xDAT\xE8I\x9F\xB7\xDC\xBB\x84\xD3Q#\x1F\xF4\x8C\aV\x93\x8F_\xC7\xBC87\xC9U\xF1\xAF\x8A\xD62\x1C5H\x86\x17\x19=B~Y*\xBC\x9D\eJeTx\x1F\x02l\t\t\xD3e\xA4\x11\x13y*\x95\x9F\xCD\xC4@\x9C"} + + it "decrypts the message" do + expect(subject.decrypt(ciphertext, key: key, aad: auth_data)).to eq(plaintext) + end + + context "and the ciphertext has been messed with" do + it "raises an exception" do + expect{ subject.decrypt(ciphertext + "\0\0\0", key: key, aad: auth_data)}.to raise_exception OpenSSL::Cipher::CipherError + end + end + end + + context "when the auth data doesn't match" do + let(:auth_data){ "asdf" } + it "raises an exception" do + expect{ subject.decrypt(ciphertext, key: key, aad: auth_data)}.to raise_exception OpenSSL::Cipher::CipherError + end + end + end + + describe '#random_iv' do + it "generates a random iv" do + expect_any_instance_of(OpenSSL::Cipher).to receive(:random_iv).and_return :iv + expect(subject.random_iv).to eq(:iv) + end + end + + describe '#random_key' do + it "generates a random key" do + expect_any_instance_of(OpenSSL::Cipher).to receive(:random_key).and_return :key + expect(subject.random_key).to eq(:key) + end + end +end diff --git a/gems/slosilo/test.sh b/gems/slosilo/test.sh new file mode 100755 index 0000000000..bd6a15484d --- /dev/null +++ b/gems/slosilo/test.sh @@ -0,0 +1,27 @@ +#!/bin/bash -xe + +iid=slosilo-test-$(date +%s) + +docker build -t $iid -f - . << EOF + FROM ruby:3.0 + WORKDIR /app + COPY Gemfile slosilo.gemspec ./ + RUN bundle + COPY . ./ + RUN bundle +EOF + +cidfile=$(mktemp -u) +docker run --cidfile $cidfile -v /app/spec/reports $iid bundle exec rake jenkins || : + +cid=$(cat $cidfile) + +docker cp $cid:/app/spec/reports spec/ +docker cp $cid:/app/coverage spec + +docker rm $cid + +# untag, will use cache next time if available but no junk will be left +docker rmi $iid + +rm $cidfile diff --git a/lib/tasks/account.rake b/lib/tasks/account.rake index dfd5e05cb4..1abbb0d00f 100644 --- a/lib/tasks/account.rake +++ b/lib/tasks/account.rake @@ -1,13 +1,10 @@ # frozen_string_literal: true namespace :"account" do - def signing_key_key account - [ "authn", account ].join(":") - end desc "Test whether the token-signing key already exists" task :exists, [ "account" ] => [ "environment" ] do |t,args| - puts !!Slosilo[signing_key_key(args[:account])] + puts !!Account.token_key(args[:account], "user") && !!Account.token_key(args[:account], "host") end desc "Create an account" @@ -17,7 +14,8 @@ namespace :"account" do api_key = Account.create(args[:account]) account = Account.new(args[:account]) $stderr.puts("Created new account '#{account.id}'") - puts("Token-Signing Public Key: #{account.token_key.to_s}") + puts("Hosts' Token-Signing Public Key:\n#{account.token_key("user").to_s}") + puts("Users' Token-Signing Public Key:\n#{account.token_key("host").to_s}") puts("API key for admin: #{api_key}") rescue Exceptions::RecordExists $stderr.puts("Account '#{args[:account]}' already exists") @@ -37,7 +35,8 @@ namespace :"account" do Account.create(args[:account]) account = Account.new(args[:account]) $stderr.puts "Created new account '#{account.id}'" - puts "Token-Signing Public Key: #{account.token_key.to_s}" + puts "Hosts' Token-Signing Public Key:\n#{account.token_key("user").to_s}" + puts "Users' Token-Signing Public Key:\n#{account.token_key("host").to_s}" role_id = "#{args[:account]}:user:admin" Role[role_id].password = args[:password] diff --git a/lib/tasks/pack.rake b/lib/tasks/pack.rake new file mode 100644 index 0000000000..477509e9e0 --- /dev/null +++ b/lib/tasks/pack.rake @@ -0,0 +1 @@ +require 'pact/tasks' \ No newline at end of file diff --git a/publish-images.sh b/publish-images.sh index 69abfc7ee0..3498a6abed 100755 --- a/publish-images.sh +++ b/publish-images.sh @@ -20,11 +20,7 @@ function print_help() { echo " --base-version=VERSION: specify base image version number to use to apply tags to" } -PUBLISH_EDGE=false PUBLISH_INTERNAL=false -PROMOTE=false -REDHAT=false -DOCKERHUB=false VERSION=$( "Bearer {\"id_token_username_field\":\"alice\"}"}) end + let(:authenticate_id_token_uppercase_request_id_token_in_header_field) do + mock_authenticate_oidc_request(request_body_data: "id_token=", request_headers: {"HTTP_AUTHORIZATION" => "Bearer {\"id_token_username_field\":\"ALICE\"}"}) + end + let(:authenticate_id_token_request_invalid_id_token_in_header_field) do mock_authenticate_oidc_request(request_body_data: "id_token=", request_headers: {"HTTP_AUTHORIZATION" => "{\"id_token_username_field\":\"alice\"}"}) end + let(:authenticate_id_token_uppercase_request_invalid_id_token_in_header_field) do + mock_authenticate_oidc_request(request_body_data: "id_token=", request_headers: {"HTTP_AUTHORIZATION" => "{\"id_token_username_field\":\"ALICE\"}"}) + end + let(:authenticate_id_token_request_empty_id_token_in_header_field) do mock_authenticate_oidc_request(request_body_data: "id_token=", request_headers: {"HTTP_AUTHORIZATION" => ""}) end @@ -78,6 +90,10 @@ def request_body(request) mock_authenticate_oidc_request(request_body_data: "", request_headers: {"HTTP_AUTHORIZATION" => "Bearer {\"id_token_username_field\":\"alice\"}"}) end + let(:authenticate_id_token_uppercase_request_missing_id_token_in_body_field) do + mock_authenticate_oidc_request(request_body_data: "", request_headers: {"HTTP_AUTHORIZATION" => "Bearer {\"id_token_username_field\":\"ALICE\"}"}) + end + let(:authenticate_id_token_request_contain_only_bearer_in_header_field) do mock_authenticate_oidc_request(request_body_data: "", request_headers: {"HTTP_AUTHORIZATION" => "Bearer"}) end @@ -156,6 +172,65 @@ def request_body(request) end end + context "with valid id upper case token" do + subject do + input_ = Authentication::AuthenticatorInput.new( + authenticator_name: 'authn-oidc', + service_id: 'my-service', + account: 'my-acct', + username: nil, + credentials: request_body(authenticate_id_token_uppercase_request), + client_ip: '127.0.0.1', + request: authenticate_id_token_uppercase_request + ) + + ::Authentication::AuthnOidc::UpdateInputWithUsernameFromIdToken.new( + verify_and_decode_token: mocked_decode_and_verify_id_token, + validate_account_exists: mock_validate_account_exists(validation_succeeded: true) + ).call( + authenticator_input: input_ + ) + end + + it "returns the input with the username inside it" do + expect(subject.username).to eql("alice") + end + + it_behaves_like( + "it fails when variable is missing or has no value", + "provider-uri" + ) + it_behaves_like( + "it fails when variable is missing or has no value", + "id-token-user-property" + ) + + context "a non-existing account" do + subject do + input_ = Authentication::AuthenticatorInput.new( + authenticator_name: 'authn-oidc', + service_id: 'my-service', + account: 'my-acct', + username: nil, + credentials: request_body(authenticate_id_token_request), + client_ip: '127.0.0.1', + request: authenticate_id_token_request + ) + + ::Authentication::AuthnOidc::UpdateInputWithUsernameFromIdToken.new( + verify_and_decode_token: mocked_decode_and_verify_id_token, + validate_account_exists: mock_validate_account_exists(validation_succeeded: false) + ).call( + authenticator_input: input_ + ) + end + + it "raises the error raised by validate_account_exists" do + expect { subject }.to raise_error(validate_account_exists_error) + end + end + end + context "with no id token username field in id token" do let(:audit_success) { false } subject do @@ -296,6 +371,33 @@ def request_body(request) end end + context "with valid upper case id token converted to lower case is in header" do + let(:audit_success) { false } + + subject do + input_ = Authentication::AuthenticatorInput.new( + authenticator_name: 'authn-oidc', + service_id: 'my-service', + account: 'my-acct', + username: nil, + credentials: request_body(authenticate_id_token_uppercase_request_id_token_in_header_field), + client_ip: '127.0.0.1', + request: authenticate_id_token_uppercase_request_id_token_in_header_field + ) + + ::Authentication::AuthnOidc::UpdateInputWithUsernameFromIdToken.new( + verify_and_decode_token: mocked_decode_and_verify_id_token, + validate_account_exists: mock_validate_account_exists(validation_succeeded: true) + ).call( + authenticator_input: input_ + ) + end + + it "returns the input with the username inside it" do + expect(subject.username).to eql("alice") + end + end + context "with invalid id token in header and in body" do let(:audit_success) { false } @@ -323,6 +425,33 @@ def request_body(request) end end + context "with invalid id upper case token in header and in body" do + let(:audit_success) { false } + + subject do + input_ = Authentication::AuthenticatorInput.new( + authenticator_name: 'authn-oidc', + service_id: 'my-service', + account: 'my-acct', + username: nil, + credentials: request_body(authenticate_id_token_uppercase_request_invalid_id_token_in_header_field), + client_ip: '127.0.0.1', + request: authenticate_id_token_uppercase_request_invalid_id_token_in_header_field + ) + + ::Authentication::AuthnOidc::UpdateInputWithUsernameFromIdToken.new( + verify_and_decode_token: mocked_decode_and_verify_id_token, + validate_account_exists: mock_validate_account_exists(validation_succeeded: true) + ).call( + authenticator_input: input_ + ) + end + + it "raises a MissingRequestParam error" do + expect { subject }.to raise_error(::Errors::Authentication::RequestBody::MissingRequestParam) + end + end + context "with empty id token in header and invalid in body" do let(:audit_success) { false } @@ -377,6 +506,33 @@ def request_body(request) end end + context "with valid id upper case token in header and empty body" do + let(:audit_success) { false } + + subject do + input_ = Authentication::AuthenticatorInput.new( + authenticator_name: 'authn-oidc', + service_id: 'my-service', + account: 'my-acct', + username: nil, + credentials: request_body(authenticate_id_token_uppercase_request_missing_id_token_in_body_field), + client_ip: '127.0.0.1', + request: authenticate_id_token_uppercase_request_missing_id_token_in_body_field + ) + + ::Authentication::AuthnOidc::UpdateInputWithUsernameFromIdToken.new( + verify_and_decode_token: mocked_decode_and_verify_id_token, + validate_account_exists: mock_validate_account_exists(validation_succeeded: true) + ).call( + authenticator_input: input_ + ) + end + + it "returns the input with the username inside it" do + expect(subject.username).to eql("alice") + end + end + context "with bearer only in id token in header and empty body" do let(:audit_success) { false } diff --git a/spec/app/domain/token_factory_spec.rb b/spec/app/domain/token_factory_spec.rb index b060af7a44..80180bea0f 100644 --- a/spec/app/domain/token_factory_spec.rb +++ b/spec/app/domain/token_factory_spec.rb @@ -1,10 +1,11 @@ # frozen_string_literal: true require 'spec_helper' +require 'support/slosilo_helper' describe TokenFactory do - before(:all) { Slosilo["authn:cucumber"] ||= Slosilo::Key.new } + before(:all) { init_slosilo_keys("cucumber")} let(:token_factory) { TokenFactory.new } let(:ttl_limit) { 5 * 60 * 60 } # five hours @@ -71,4 +72,33 @@ end end end + describe '.signing_key' do + context 'Hosts key and user key are in db' do + it 'return host key' do + account = "cucumber" + key = token_factory.signing_key("host/myhost", account).to_s + expected = token_key(account, "host").to_s + expect(key).to eq(expected) + end + it 'return users key' do + account = "cucumber" + key = token_factory.signing_key("myuser", account).to_s + expected = token_key(account, "user").to_s + expect(key).to eq(expected) + end + it 'Host key is different from users key' do + account = "cucumber" + user_key = token_factory.signing_key("myuser", account).to_s + host_key = token_factory.signing_key("host/myhost", account).to_s + expect(user_key).to_not eq(host_key) + end + end + context 'User and Host Key doesnt exists in db' do + it 'Raises error' do + account = "cucumber2" + expect{token_factory.signing_key("myuser", account)}.to raise_error(TokenFactory::NoSigningKey, "Signing key not found for account '#{token_id(account, "user")}'") + expect{token_factory.signing_key("host/myhost", account)}.to raise_error(TokenFactory::NoSigningKey, "Signing key not found for account '#{token_id(account, "host")}'") + end + end + end end diff --git a/spec/conjurctl/account_spec.rb b/spec/conjurctl/account_spec.rb index 0d79e1cc22..ac21069b44 100644 --- a/spec/conjurctl/account_spec.rb +++ b/spec/conjurctl/account_spec.rb @@ -1,26 +1,41 @@ require 'spec_helper' require 'open3' -describe "account" do - def delete_account(name) - system("conjurctl account delete #{name}") - end +def delete_account(name) + system("conjurctl account delete #{name}") +end + +def create_default_account() + system("conjurctl account create") +end +describe "account" do it "creates default account when no name provided" do stdout_str, = Open3.capture3( "conjurctl account create" ) expect(stdout_str).to include("API key for admin") - expect(Slosilo["authn:default"]).to be + expect(token_key("default", "host")).to be + expect(token_key("default", "user")).to be expect(Role["default:user:admin"]).to be + expect(Credentials["default:user:admin"]).to be delete_account("default") end + it "delete account" do + create_default_account() + delete_account("default") + expect(token_key("default", "host")).not_to be + expect(token_key("default", "user")).not_to be + expect(Role["default:user:admin"]).not_to be + expect(Credentials["default:user:admin"]).not_to be + end + context "create with name demo" do after(:each) do delete_account("demo") end - + let(:password) { "MySecretP,@SS1()!" } let(:create_account_with_password_and_name_flag) do "conjurctl account create --name demo --password-from-stdin" @@ -33,15 +48,19 @@ def delete_account(name) it "with no flags" do stdout_str, = Open3.capture3("conjurctl account create demo") expect(stdout_str).to include("API key for admin") - expect(Slosilo["authn:demo"]).to be + expect(token_key("demo", "host")).to be + expect(token_key("demo", "user")).to be expect(Role["demo:user:admin"]).to be + expect(Credentials["demo:user:admin"]).to be end it "with the name flag" do stdout_str, = Open3.capture3("conjurctl account create --name demo") expect(stdout_str).to include("API key for admin") - expect(Slosilo["authn:demo"]).to be + expect(token_key("demo", "host")).to be + expect(token_key("demo", "user")).to be expect(Role["demo:user:admin"]).to be + expect(Credentials["demo:user:admin"]).to be end it "with predefined password and account name flag" do @@ -49,8 +68,10 @@ def delete_account(name) create_account_with_password_and_name_flag, stdin_data: password ) expect(stdout_str).to include("Password is set") - expect(Slosilo["authn:demo"]).to be + expect(token_key("demo", "host")).to be + expect(token_key("demo", "user")).to be expect(Role["demo:user:admin"]).to be + expect(Credentials["demo:user:admin"]).to be end it "with predefined password" do @@ -58,16 +79,20 @@ def delete_account(name) create_account_with_password_flag, stdin_data: password ) expect(stdout_str).to include("Password is set") - expect(Slosilo["authn:demo"]).to be + expect(token_key("demo", "host")).to be + expect(token_key("demo", "user")).to be expect(Role["demo:user:admin"]).to be + expect(Credentials["demo:user:admin"]).to be end it "with both an account name argument and flag" do system( "conjurctl account create --name demo ingored_account_name" ) - expect(Slosilo["authn:demo"]).to be + expect(token_key("demo", "host")).to be + expect(token_key("demo", "user")).to be expect(Role["demo:user:admin"]).to be + expect(Credentials["demo:user:admin"]).to be end it "and with invalid password" do @@ -75,8 +100,10 @@ def delete_account(name) create_account_with_password_flag, stdin_data: "invalid" ) expect(stderr_str).to include("CONJ00046E") - expect(Slosilo["authn:demo"]).not_to be + expect(token_key("demo", "host")).not_to be + expect(token_key("demo", "user")).not_to be expect(Role["demo:user:admin"]).not_to be + expect(Credentials["demo:user:admin"]).not_to be end end end diff --git a/spec/conjurctl/server_spec.rb b/spec/conjurctl/server_spec.rb index 97c730ac09..9df0294b0b 100644 --- a/spec/conjurctl/server_spec.rb +++ b/spec/conjurctl/server_spec.rb @@ -22,7 +22,8 @@ def wait_for_conjur "conjurctl server --password-from-stdin" ) expect(stderr_str).to include("account is required") - expect(Slosilo["authn:demo"]).not_to be + expect(token_key("demo", "host")).not_to be + expect(token_key("demo", "user")).not_to be expect(Role["demo:user:admin"]).not_to be end @@ -31,7 +32,8 @@ def wait_for_conjur 'conjurctl server --account demo' ) do wait_for_conjur - expect(Slosilo["authn:demo"]).to be + expect(token_key("demo", "host")).to be + expect(token_key("demo", "user")).to be expect(Role["demo:user:admin"]).to be end end @@ -42,7 +44,8 @@ def wait_for_conjur conjurctl server --account demo --password-from-stdin ") do wait_for_conjur - expect(Slosilo["authn:demo"]).to be + expect(token_key("demo", "host")).to be + expect(token_key("demo", "user")).to be expect(Role["demo:user:admin"]).to be end end diff --git a/spec/controllers/authenticate_controller_authn_k8s_spec.rb b/spec/controllers/authenticate_controller_authn_k8s_spec.rb index b2f73f5102..4c420f22f5 100644 --- a/spec/controllers/authenticate_controller_authn_k8s_spec.rb +++ b/spec/controllers/authenticate_controller_authn_k8s_spec.rb @@ -161,7 +161,7 @@ def capture_args(obj, *methods) # Allows API calls to be made as the admin user let(:admin_request_env) do - { 'HTTP_AUTHORIZATION' => "Token token=\"#{Base64.strict_encode64(Slosilo["authn:rspec"].signed_token("admin").to_json)}\"" } + { 'HTTP_AUTHORIZATION' => "Token token=\"#{Base64.strict_encode64(token_key("rspec", "user").signed_token("admin").to_json)}\"" } end before(:all) do @@ -169,7 +169,7 @@ def capture_args(obj, *methods) DatabaseCleaner.clean_with(:truncation) # Init Slosilo key - Slosilo["authn:rspec"] ||= Slosilo::Key.new + init_slosilo_keys("rspec") Role.create(role_id: 'rspec:user:admin') end diff --git a/spec/controllers/authenticate_controller_spec.rb b/spec/controllers/authenticate_controller_spec.rb index 24f24567e5..3d58d323f0 100644 --- a/spec/controllers/authenticate_controller_spec.rb +++ b/spec/controllers/authenticate_controller_spec.rb @@ -52,7 +52,7 @@ end context "with Token auth" do - include_context "authenticate Token" + include_context "authenticate user Token" it "is unauthorized" do post(authenticate_url, env: request_env) @@ -101,5 +101,5 @@ def invoke end end - before(:all) { Slosilo["authn:rspec"] ||= Slosilo::Key.new } + before(:all) { init_slosilo_keys("rspec") } end diff --git a/spec/controllers/credentials_controller_spec.rb b/spec/controllers/credentials_controller_spec.rb index cf9174b264..f2b544ea44 100644 --- a/spec/controllers/credentials_controller_spec.rb +++ b/spec/controllers/credentials_controller_spec.rb @@ -5,7 +5,9 @@ describe CredentialsController, :type => :request do include_context "existing account" - before(:all) { Slosilo["authn:rspec"] ||= Slosilo::Key.new } + before(:all) { + init_slosilo_keys("rspec") + } let(:login) { "u-#{random_hex}" } let(:host_login) { "h-#{random_hex}" } @@ -39,7 +41,7 @@ context "with token auth" do include_context "create user" - include_context "authenticate Token" + include_context "authenticate user Token" it_should_behave_like "authentication denied" end diff --git a/spec/controllers/edge_controller_spec.rb b/spec/controllers/edge_controller_spec.rb new file mode 100644 index 0000000000..4a922eaece --- /dev/null +++ b/spec/controllers/edge_controller_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe EdgeController, :type => :request do + let(:account) { "rspec" } + let(:host_id) {"#{account}:host:edge/edge"} + let(:other_host_id) {"#{account}:host:data/other"} + + before do + init_slosilo_keys(account) + @current_user = Role.find_or_create(role_id: host_id) + @other_user = Role.find_or_create(role_id: other_host_id) + end + + let(:update_slosilo_keys_url) do + "/edge/slosilo_keys/#{account}" + end + + let(:get_hosts) do + "/edge/hosts/#{account}" + end + + let(:token_auth_header) do + bearer_token = token_key(account, "host").signed_token(@current_user.login) + token_auth_str = + "Token token=\"#{Base64.strict_encode64(bearer_token.to_json)}\"" + { 'HTTP_AUTHORIZATION' => token_auth_str } + end + + context "slosilo keys in DB" do + it "Slosilo keys equals to key in DB, Host and Role are correct" do + #add edge-hosts to edge/edge-hosts group + Role.create(role_id: "#{account}:group:edge/edge-hosts") + RoleMembership.create(role_id: "#{account}:group:edge/edge-hosts", member_id: host_id, admin_option: false, ownership:false) + + #get the Slosilo key the URL request + get(update_slosilo_keys_url, env: token_auth_header) + expect(response.code).to eq("200") + + #get the Slosilo key from DB + key = token_key(account, "host") + private_key = key.to_der.unpack("H*")[0] + fingerprint = key.fingerprint + + expected = {"slosiloKeys" => [{"privateKey"=> private_key,"fingerprint"=>fingerprint}]} + response_json = JSON.parse(response.body) + expect(response_json).to include(expected) + end + + it "Host is Edge but no Role exists at all" do + #get the Slosilo key the URL request + get(update_slosilo_keys_url, env: token_auth_header) + expect(response.code).to eq("403") + end + + it "Host is Edge but the host is member in wrong role" do + #add edge-hosts to edge2/edge-hosts group + Role.create(role_id: "#{account}:group:edge2/edge-hosts") + RoleMembership.create(role_id: "#{account}:group:edge2/edge-hosts", member_id: host_id, admin_option: false, ownership:false) + + #get the Slosilo key the URL request + get(update_slosilo_keys_url, env: token_auth_header) + expect(response.code).to eq("403") + end + end + + context "Host" do + it "Check HMAC" do + #add edge-hosts to edge/edge-hosts group + Role.create(role_id: "#{account}:group:edge/edge-hosts") + RoleMembership.create(role_id: "#{account}:group:edge/edge-hosts", member_id: host_id, admin_option: false, ownership:false) + get(get_hosts, env: token_auth_header) + expect(response.code).to eq("200") + expect(response).to be_ok + expect(response.body).to include("api_key".strip) + expect(response.body).to include("salt".strip) + @result = JSON.parse(response.body) + encoded_api_key = @result['hosts'][0]['api_key'] + encoded_salt = @result['hosts'][0]['salt'] + salt = Base64.strict_decode64(encoded_salt) + test_api_key = Base64.strict_encode64(Cryptography.hmac_api_key(@other_user.credentials.api_key, salt)) + expect(test_api_key).to eq(encoded_api_key) + end + end +end \ No newline at end of file diff --git a/spec/controllers/health_controller_spec.rb b/spec/controllers/health_controller_spec.rb new file mode 100644 index 0000000000..9f8f4498d5 --- /dev/null +++ b/spec/controllers/health_controller_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe HealthController, :type => :controller do + describe "GET health" do + it 'renders the health route sanity' do + get :health + expect(response.code).to eq("200") + end + + context "negative" do + it 'renders the health route fails' do + expect_any_instance_of(HealthController).to receive(:check_db_connection).and_return(false) + get :health + expect(response.code).to eq("503") + end + end + + end +end diff --git a/spec/controllers/policies_controller_spec.rb b/spec/controllers/policies_controller_spec.rb index d79b3cca9e..5e8bd7f47d 100644 --- a/spec/controllers/policies_controller_spec.rb +++ b/spec/controllers/policies_controller_spec.rb @@ -16,7 +16,7 @@ DatabaseCleaner.strategy = :truncation # init Slosilo key - Slosilo["authn:rspec"] ||= Slosilo::Key.new + init_slosilo_keys("rspec") end after(:all) do @@ -48,7 +48,7 @@ def variable(name) # This will require nontrivial refactoring and may be better waiting for a # larger overhaul of the test code. let(:token_auth_header) do - bearer_token = Slosilo["authn:rspec"].signed_token(current_user.login) + bearer_token = token_key("rspec", "user").signed_token(current_user.login) token_auth_str = "Token token=\"#{Base64.strict_encode64(bearer_token.to_json)}\"" { 'HTTP_AUTHORIZATION' => token_auth_str } diff --git a/spec/controllers/resources_controller_spec.rb b/spec/controllers/resources_controller_spec.rb index 0fbb48eb9b..b93651051c 100644 --- a/spec/controllers/resources_controller_spec.rb +++ b/spec/controllers/resources_controller_spec.rb @@ -7,7 +7,7 @@ describe ResourcesController, type: :request do before do - Slosilo["authn:rspec"] ||= Slosilo::Key.new + init_slosilo_keys("rspec") end let(:current_user) { Role.find_or_create(role_id: 'rspec:user:admin') } diff --git a/spec/controllers/roles_controller_spec.rb b/spec/controllers/roles_controller_spec.rb index 17bbce994f..6c99051e2b 100644 --- a/spec/controllers/roles_controller_spec.rb +++ b/spec/controllers/roles_controller_spec.rb @@ -11,8 +11,7 @@ describe RolesController, type: :request do before do - Slosilo["authn:rspec"] ||= Slosilo::Key.new - + init_slosilo_keys("rspec") # Load the test policy into Conjur put( '/policies/rspec/policy/root', diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb index 3bc639b4bc..2aa2ac6d14 100644 --- a/spec/models/account_spec.rb +++ b/spec/models/account_spec.rb @@ -14,7 +14,8 @@ def create_account it "succeeds" do create_account - expect(Slosilo["authn:#{account_name}"]).to be + expect(token_key(account_name, "host")).to be + expect(token_key(account_name, "user")).to be admin = Role["#{account_name}:user:admin"] expect(admin).to be expect(admin.credentials).to be @@ -75,7 +76,8 @@ def create_account create_account Account.new(account_name).delete - expect(Slosilo["authn:#{account_name}"]).to_not be + expect(token_key(account_name, "host")).to_not be + expect(token_key(account_name, "user")).to_not be expect(Role["#{account_name}:user:admin"]).to_not be end end diff --git a/spec/rack/slosilo.rb b/spec/rack/slosilo.rb new file mode 100644 index 0000000000..56efac67ad --- /dev/null +++ b/spec/rack/slosilo.rb @@ -0,0 +1,15 @@ +require 'spec_helper' +describe "Slosilo key" do + before(:all) { + init_slosilo_keys("rspec") + } + context "Update existing key" do + it "possible to update existing key" do + new_key = Slosilo::Key.new + Slosilo["authn:rspec:host:current"] = new_key + current_key = Slosilo["authn:rspec:host:current"] + expect(current_key.to_der.unpack("H*")[0]).to eq(new_key.to_der.unpack("H*")[0]) + expect(current_key.fingerprint).to eq(new_key.fingerprint) + end + end +end \ No newline at end of file diff --git a/spec/services_consumers/pact_conjur_api_java.rb b/spec/services_consumers/pact_conjur_api_java.rb new file mode 100644 index 0000000000..a1cf47fd42 --- /dev/null +++ b/spec/services_consumers/pact_conjur_api_java.rb @@ -0,0 +1,50 @@ +require_relative '../../gems/conjur-rack/lib/conjur/rack/authenticator.rb' + +account = "cucumber" +user_id = "user:yoav" +password = "FruitBucket12#" +apiKey = "wow" +Pact.provider_states_for "Conjur API java" do + + set_up() do + path = File.join Rails.root, 'spec','services_consumers','rsa.pem' + rsa = OpenSSL::PKey::RSA.new File.read "#{path}" + init_slosilo_key_static_value(account, rsa) + Role.find_or_create(role_id: "#{account}:#{user_id}") + role = Role["#{account}:#{user_id}"] + token = token_auth_header(role: role, account: account) + puts "Token is - #{token}" + + role.password = password + role.save + role.api_key + role.credentials.static_api_key (apiKey) + role.credentials.save + end + + provider_state "Authenticate this" do + set_up do + allow_any_instance_of(TokenFactory).to receive(:signed_token).and_return("{\"protected\": \"eyJhbGciOiJjb25qdXIub3JnL3Nsb3NpbG8vdjIiLCJraWQiOiJmZTZhY2E2MmQ3ZTY4YWY0MjJkODAxMDA3ZjcyMTkwZDlmNWNhNjQ0NTkxMDg3OTRlNWIwNzFiZjg4YTNhZDFhIn0=\"," + + "\"payload\": \"eyJzdWIiOiJ5b2F2IiwiZXhwIjoxNjg3MDc3OTA5LCJpYXQiOjE2ODcwNzc0Mjl9\"," + + "\"signature\": \"oRzP7jIjw7VG1-h9M7_iDZOjvlYQrggNHLq71zu9kjpSahGSRUbveegFuycm3ReLrDX2M-OuJpq1L7-sEpLYI1FcJ4in2HJ5UVPnBI3MOT8YJyEqyW86pF_dRoCYEwzQteHLT_rR5bLcFn9rAFqqfWFtXcNRF9Q3dbL-gr6ZTrXjxNOrMlFv3qfpb8XtXldtkRFWZKoIiUOq0MBOLgCyo4GOVWJt0s9zvVoG5fYNQj4WYIuC_080qfr5jZsVm5i2TtzfIIffkuZvkCzDHKMv8hkqAdOSJ3k4tbN9C8BXn_owSMXO5LQbJVsdPsGEh6UkY9xmcAOHn2mH03fc6kMblGpO75GvEdELt1uwrQt04f4hCEK0x1wEE7r4kCnm0BDO\"" + + "}") + + # allow_any_instance_of(TokenFactory).to receive(:signed_token).and_return("wow") + + # allow_any_instance_of(AuthenticateController).to receive(:authenticate).and_return(token) + end + end + + provider_state "Login" do + set_up do + + end + end + + provider_state "get secret" do + set_up do + # Your set up code goes here + end + end + +end diff --git a/spec/services_consumers/pact_helper.rb b/spec/services_consumers/pact_helper.rb new file mode 100644 index 0000000000..a072415dbf --- /dev/null +++ b/spec/services_consumers/pact_helper.rb @@ -0,0 +1,28 @@ +require 'pact/provider/rspec' +require_relative '../services_consumers/pact_conjur_api_java.rb' +require 'spec_helper' +provider_version = ENV['GIT_COMMIT'] || `git rev-parse --verify HEAD`.strip +provider_branch = ENV['GIT_BRANCH'] || `git name-rev --name-only HEAD`.strip +publish_flag = ENV['PUBLISH_VERIFICATION_RESULTS'] == 'true' # or some way of detecting you're running on CI like ENV['CI'] == 'true' + + + + +Pact.service_provider "Conjur Cloud" do + app_version provider_version + app_version_branch provider_branch + publish_verification_results publish_flag + # honours_pact_with 'conjur-app' do + honours_pacts_from_pact_broker do + + # This example points to a local file, however, on a real project with a continuous + # integration box, you would use a [Pact Broker](https://github.com/pact-foundation/pact_broker) or publish your pacts as artifacts, + # and point the pact_uri to the pact published by the last successful build. + + pact_broker_base_url 'https://cyberark2.pactflow.io' , { token: '_e68R-wKTXNqyJ2KCUDy5A' } + verbose true + enable_pending true + + end + +end \ No newline at end of file diff --git a/spec/services_consumers/rsa.pem b/spec/services_consumers/rsa.pem new file mode 100755 index 0000000000..165f981ef9 --- /dev/null +++ b/spec/services_consumers/rsa.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC2vSThMua7kbOs +Hr96PFShSVStXPHm2hBIfeLEi7uiNkRZvA1La/6lHhRJW2mhobT2UQtuS/z4Roqd +IFehaDLQYOoqmPEWTBCY8SRyd+xV15Tr0sPFZSSVpEWmRPUzkSIGFIKXHYYiE5qq +iR2C7ZOlYN5xoI0sseuvqanIItEWhfZBZcU1Rd/fLWDAchmE/fOENhIKEwFJIClZ +nTOilY43wyFzvsByb2IzRDwsjnPjM5fE3p6kfwm0ESvp8fVKIxE4SJjtTe4G4CRT +8QOdn4/xIMOh5RpNYeQ2io+dtdMhvaAjDa4/F/l9oUXOp9gO4ES0/nXjvZs0nE9h +3Rj6/1ANAgMBAAECggEACTKwkEhKXl8ZWtzKWQn/3YVuJnLC91J7FtcFSszYwzO1 +kYm6ZpU3QlLnBQE9qiXUUMCoEWNwRoPwdrO5zXVL93qlVsMPgYaPIE+k/MEGC5UY +GR2o/n7WjlSwL8wRRojz3Df8kcQp6/2uH47axiwUrjKXx8Lj0D/dY+BnnBwKh7Zd +LXeQiTljRJ1Z/vQpLOZavKhvkRSq3rkfSBCN3psdtlWMZNTRADtNQvaDlwgKpDaK +GXi2SX42olpVXqI4z99gYEndbS8IcSkEOA48nj7yP/mM6m6YuYbbMhygEV7ub5X2 +fQ1v1u1ukjCDyFoAO5lJWQOEdZbAefzBw1z3DdbYAQKBgQDk76QyChn3B+sbKTyr +y6JkHGenItpE9fgNkkL+uY2a6Byt0KSeg7G11tcjG4dC4x5mlk+0YVNeWqVR2rTT +W/UFpX0quAVDGGtnolBcAKPRywJCoS58RNEjc/ZLwxEt8C1F9W1hqACnGfFijCEu +zppcK5Q/g+20ZSkBE1jNyURYDQKBgQDMV2wD1DOS2qoKbo0D8Yqd+ZL+UBsw+U9W +ahB3rVWbxsD+j0bsenIMLs/OvJVeupLFlNtCLFDaCwXwX6FxALGHIJ5nH3Xm2Nif +0bOJwYfIFqcFFo7o/Y8CTNPFW6RyZlLnEbaoaFoLAKz11leCa93EMbtF6A0MpMnL ++pue2TDYAQKBgAr6ry4zK9GcEJtp294FUxFoB1SNqFgcT7we4pWiwnbGYPG5CtwL +Rs+IsSKe5FrB16hl1wa4kC37POhzC68DPvn5WMperunr1uLOdvoikbiy+rXFUxxk +lH8VxZ8WiNFiLlmnjopba0SYSupF/CVphR+ce2ycYB8BpvjFO8IKv/tVAoGBAKGs +GV3+Kr/qGdcfGJsiJFZQ/CgSITFiT49DY7wMfEs/ubquiaLYThutfeVH97Z3T2Dc +IY39mgqp5g0E3KqXrd4HxMw6bAI1V087jx1S7ac1pPe1nWRiGR/swTDJDRcmNjl0 +HlyoE3XZFv96ksoxpchwwIbSzmBOkQq+QRoxJSABAoGBAMrWiWLZX3tbSI1yMXwg +z+Donhx/lb++CBKduLLhU0feXgx7DVIxqEP2XfOvxsmgBTzXIYK0wU31brQ7C8f2 +l3dDsxrZbZA11kjRkRx2Lo/gFXTkhqLPUldMXwX/KCoEcI+5SfA6P2u2TuKG2Z+m +M3X2BA4O3wp14w3Mj4pINZno +-----END PRIVATE KEY----- \ No newline at end of file diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 3fb8bcd27a..f7e6150fca 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -34,6 +34,10 @@ # not under the default load paths. $LOAD_PATH << './bin/conjur-cli' +# Add Gems to specs +Rails.root.join("gems") +$LOAD_PATH << './gems/conjur-rack/lib/conjur/rack' + # Please note, VCR is configured to only run when the `:vcr` arguement # is passed to the RSpec block. Calling VCR with `VCR.use_cassette` will # not work. @@ -45,17 +49,20 @@ config.default_cassette_options = { decode_compressed_response: true } + config.ignore_request do |request| + request.uri.include?('cyberark2.pactflow.io') + end end RSpec.configure do |config| config.before(:suite) do - DatabaseCleaner.strategy = :transaction + # DatabaseCleaner.strategy = :transaction end config.around(:each) do |example| - DatabaseCleaner.cleaning do + # DatabaseCleaner.cleaning do example.run - end + # end end config.around do |example| @@ -102,7 +109,7 @@ def secret_logged?(secret) # :reek:UtilityFunction def access_token_for(user, account: 'rspec') # Configure Slosilo to produce valid access tokens - slosilo = Slosilo["authn:#{account}"] ||= Slosilo::Key.new + slosilo = Slosilo[token_id(account, "user")] ||= Slosilo::Key.new bearer_token = slosilo.issue_jwt(sub: user) "Token token=\"#{Base64.strict_encode64(bearer_token.to_json)}\"" end @@ -161,8 +168,9 @@ def as_user(user, &block) Process.uid = Process.euid = prev end -def token_auth_header(role:, account: 'rspec') - bearer_token = Slosilo["authn:#{account}"].signed_token(role.login) +def token_auth_header(role:, account: 'rspec', is_user: true) + slosilo_key = is_user ? token_key(account, "user") : token_key(account, "host") + bearer_token = slosilo_key.signed_token(role.login) base64_token = Base64.strict_encode64(bearer_token.to_json) { 'HTTP_AUTHORIZATION' => "Token token=\"#{base64_token}\"" } diff --git a/spec/support/authentication.rb b/spec/support/authentication.rb index 286f2a4c6e..74aaac29c0 100644 --- a/spec/support/authentication.rb +++ b/spec/support/authentication.rb @@ -33,9 +33,9 @@ end end -shared_context "authenticate Token" do +shared_context "authenticate user Token" do let(:params) { { account: account } } - let(:bearer_token) { Slosilo["authn:rspec"].signed_token(login) } + let(:bearer_token) { token_key("rspec", "user").signed_token(login) } let(:token_auth_header) do "Token token=\"#{Base64.strict_encode64(bearer_token.to_json)}\"" end diff --git a/spec/support/slosilo_helper.rb b/spec/support/slosilo_helper.rb new file mode 100644 index 0000000000..af3ee035c8 --- /dev/null +++ b/spec/support/slosilo_helper.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +def token_key(account, role) + Slosilo[token_id(account, role)] +end + +def token_id(account, role) + "authn:#{account}:#{role}:current" +end + +def init_slosilo_keys(account) + Slosilo[token_id(account, "host")] ||= Slosilo::Key.new + Slosilo[token_id(account, "user")] ||= Slosilo::Key.new +end + +def init_slosilo_key_static_value(account,value) + Slosilo[token_id(account, "user")] ||= Slosilo::Key.new(value) +end \ No newline at end of file