├── .dockerignore ├── .github └── CODEOWNERS ├── .gitignore ├── .gitleaks.toml ├── .kateproject ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Gemfile ├── Jenkinsfile ├── LICENSE ├── README.md ├── Rakefile ├── SECURITY.md ├── dev ├── Dockerfile.dev └── docker-compose.yml ├── lib ├── slosilo.rb ├── slosilo │ ├── adapters │ │ ├── abstract_adapter.rb │ │ ├── file_adapter.rb │ │ ├── memory_adapter.rb │ │ ├── mock_adapter.rb │ │ ├── sequel_adapter.rb │ │ └── sequel_adapter │ │ │ └── migration.rb │ ├── attr_encrypted.rb │ ├── errors.rb │ ├── jwt.rb │ ├── key.rb │ ├── keystore.rb │ ├── random.rb │ ├── symmetric.rb │ └── version.rb └── tasks │ └── slosilo.rake ├── publish-rubygem.sh ├── slosilo.gemspec ├── spec ├── encrypted_attributes_spec.rb ├── file_adapter_spec.rb ├── jwt_spec.rb ├── key_spec.rb ├── keystore_spec.rb ├── random_spec.rb ├── sequel_adapter_spec.rb ├── slosilo_spec.rb ├── spec_helper.rb └── symmetric_spec.rb └── test.sh /.dockerignore: -------------------------------------------------------------------------------- 1 | /Gemfile.lock 2 | /spec/reports 3 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @cyberark/conjur-core-team @conjurinc/conjur-core-team @conjurdemos/conjur-core-team 2 | 3 | # Changes to .trivyignore require Security Architect approval 4 | .trivyignore @cyberark/security-architects @conjurinc/security-architects @conjurdemos/security-architects 5 | 6 | # Changes to .codeclimate.yml require Quality Architect approval 7 | .codeclimate.yml @cyberark/quality-architects @conjurinc/quality-architects @conjurdemos/quality-architects 8 | 9 | # Changes to SECURITY.md require Security Architect approval 10 | SECURITY.md @cyberark/security-architects @conjurinc/security-architects @conjurdemos/security-architects 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | .rvmrc 19 | .project 20 | .kateproject.d 21 | .idea 22 | -------------------------------------------------------------------------------- /.gitleaks.toml: -------------------------------------------------------------------------------- 1 | title = "Secretless Broker gitleaks config" 2 | 3 | # This is the config file for gitleaks. You can configure gitleaks what to search for and what to whitelist. 4 | # If GITLEAKS_CONFIG environment variable 5 | # is set, gitleaks will load configurations from that path. If option --config-path is set, gitleaks will load 6 | # configurations from that path. Gitleaks does not whitelist anything by default. 7 | # - https://www.ndss-symposium.org/wp-content/uploads/2019/02/ndss2019_04B-3_Meli_paper.pdf 8 | # - https://github.com/dxa4481/truffleHogRegexes/blob/master/truffleHogRegexes/regexes.json 9 | [[rules]] 10 | description = "AWS Client ID" 11 | regex = '''(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}''' 12 | tags = ["key", "AWS"] 13 | 14 | [[rules]] 15 | description = "AWS Secret Key" 16 | regex = '''(?i)aws(.{0,20})?(?-i)['\"][0-9a-zA-Z\/+]{40}['\"]''' 17 | tags = ["key", "AWS"] 18 | 19 | [[rules]] 20 | description = "AWS MWS key" 21 | regex = '''amzn\.mws\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}''' 22 | tags = ["key", "AWS", "MWS"] 23 | 24 | [[rules]] 25 | description = "PKCS8" 26 | regex = '''-----BEGIN PRIVATE KEY-----''' 27 | tags = ["key", "PKCS8"] 28 | 29 | [[rules]] 30 | description = "RSA" 31 | regex = '''-----BEGIN RSA PRIVATE KEY-----''' 32 | tags = ["key", "RSA"] 33 | 34 | [[rules]] 35 | description = "SSH" 36 | regex = '''-----BEGIN OPENSSH PRIVATE KEY-----''' 37 | tags = ["key", "SSH"] 38 | 39 | [[rules]] 40 | description = "PGP" 41 | regex = '''-----BEGIN PGP PRIVATE KEY BLOCK-----''' 42 | tags = ["key", "PGP"] 43 | 44 | [[rules]] 45 | description = "Facebook Secret Key" 46 | regex = '''(?i)(facebook|fb)(.{0,20})?(?-i)['\"][0-9a-f]{32}['\"]''' 47 | tags = ["key", "Facebook"] 48 | 49 | [[rules]] 50 | description = "Facebook Client ID" 51 | regex = '''(?i)(facebook|fb)(.{0,20})?['\"][0-9]{13,17}['\"]''' 52 | tags = ["key", "Facebook"] 53 | 54 | [[rules]] 55 | description = "Facebook access token" 56 | regex = '''EAACEdEose0cBA[0-9A-Za-z]+''' 57 | tags = ["key", "Facebook"] 58 | 59 | [[rules]] 60 | description = "Twitter Secret Key" 61 | regex = '''(?i)twitter(.{0,20})?['\"][0-9a-z]{35,44}['\"]''' 62 | tags = ["key", "Twitter"] 63 | 64 | [[rules]] 65 | description = "Twitter Client ID" 66 | regex = '''(?i)twitter(.{0,20})?['\"][0-9a-z]{18,25}['\"]''' 67 | tags = ["client", "Twitter"] 68 | 69 | [[rules]] 70 | description = "Github" 71 | regex = '''(?i)github(.{0,20})?(?-i)['\"][0-9a-zA-Z]{35,40}['\"]''' 72 | tags = ["key", "Github"] 73 | 74 | [[rules]] 75 | description = "LinkedIn Client ID" 76 | regex = '''(?i)linkedin(.{0,20})?(?-i)['\"][0-9a-z]{12}['\"]''' 77 | tags = ["client", "Twitter"] 78 | 79 | [[rules]] 80 | description = "LinkedIn Secret Key" 81 | regex = '''(?i)linkedin(.{0,20})?['\"][0-9a-z]{16}['\"]''' 82 | tags = ["secret", "Twitter"] 83 | 84 | [[rules]] 85 | description = "Slack" 86 | regex = '''xox[baprs]-([0-9a-zA-Z]{10,48})?''' 87 | tags = ["key", "Slack"] 88 | 89 | [[rules]] 90 | description = "EC" 91 | regex = '''-----BEGIN EC PRIVATE KEY-----''' 92 | tags = ["key", "EC"] 93 | 94 | [[rules]] 95 | description = "Generic API key" 96 | regex = '''(?i)(api_key|apikey)(.{0,20})?['|"][0-9a-zA-Z]{32,45}['|"]''' 97 | tags = ["key", "API", "generic"] 98 | 99 | [[rules]] 100 | description = "Generic Secret" 101 | regex = '''(?i)secret(.{0,20})?['|"][0-9a-zA-Z]{32,45}['|"]''' 102 | tags = ["key", "Secret", "generic"] 103 | 104 | [[rules]] 105 | description = "Google API key" 106 | regex = '''AIza[0-9A-Za-z\\-_]{35}''' 107 | tags = ["key", "Google"] 108 | 109 | [[rules]] 110 | description = "Google Cloud Platform API key" 111 | regex = '''(?i)(google|gcp|youtube|drive|yt)(.{0,20})?['\"][AIza[0-9a-z\\-_]{35}]['\"]''' 112 | tags = ["key", "Google", "GCP"] 113 | 114 | [[rules]] 115 | description = "Google OAuth" 116 | regex = '''(?i)(google|gcp|auth)(.{0,20})?['"][0-9]+-[0-9a-z_]{32}\.apps\.googleusercontent\.com['"]''' 117 | tags = ["key", "Google", "OAuth"] 118 | 119 | [[rules]] 120 | description = "Google OAuth access token" 121 | regex = '''ya29\.[0-9A-Za-z\-_]+''' 122 | tags = ["key", "Google", "OAuth"] 123 | 124 | [[rules]] 125 | description = "Heroku API key" 126 | regex = '''(?i)heroku(.{0,20})?['"][0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}['"]''' 127 | tags = ["key", "Heroku"] 128 | 129 | [[rules]] 130 | description = "MailChimp API key" 131 | regex = '''(?i)(mailchimp|mc)(.{0,20})?['"][0-9a-f]{32}-us[0-9]{1,2}['"]''' 132 | tags = ["key", "Mailchimp"] 133 | 134 | [[rules]] 135 | description = "Mailgun API key" 136 | regex = '''(?i)(mailgun|mg)(.{0,20})?['"][0-9a-z]{32}['"]''' 137 | tags = ["key", "Mailgun"] 138 | 139 | [[rules]] 140 | description = "Password in URL" 141 | regex = '''[a-zA-Z]{3,10}:\/\/[^\/\s:@]{3,20}:[^\/\s:@]{3,20}@.{1,100}\/?.?''' 142 | tags = ["key", "URL", "generic"] 143 | 144 | [[rules]] 145 | description = "PayPal Braintree access token" 146 | regex = '''access_token\$production\$[0-9a-z]{16}\$[0-9a-f]{32}''' 147 | tags = ["key", "Paypal"] 148 | 149 | [[rules]] 150 | description = "Picatic API key" 151 | regex = '''sk_live_[0-9a-z]{32}''' 152 | tags = ["key", "Picatic"] 153 | 154 | [[rules]] 155 | description = "Slack Webhook" 156 | regex = '''https://hooks.slack.com/services/T[a-zA-Z0-9_]{8}/B[a-zA-Z0-9_]{8}/[a-zA-Z0-9_]{24}''' 157 | tags = ["key", "slack"] 158 | 159 | [[rules]] 160 | description = "Stripe API key" 161 | regex = '''(?i)stripe(.{0,20})?['\"][sk|rk]_live_[0-9a-zA-Z]{24}''' 162 | tags = ["key", "Stripe"] 163 | 164 | [[rules]] 165 | description = "Square access token" 166 | regex = '''sq0atp-[0-9A-Za-z\-_]{22}''' 167 | tags = ["key", "square"] 168 | 169 | [[rules]] 170 | description = "Square OAuth secret" 171 | regex = '''sq0csp-[0-9A-Za-z\\-_]{43}''' 172 | tags = ["key", "square"] 173 | 174 | [[rules]] 175 | description = "Twilio API key" 176 | regex = '''(?i)twilio(.{0,20})?['\"][0-9a-f]{32}['\"]''' 177 | tags = ["key", "twilio"] 178 | 179 | [whitelist] 180 | files = [ 181 | "(.*?)(jpg|gif|doc|pdf|bin)$", 182 | ".gitleaks.toml" 183 | ] 184 | regexes = [ 185 | ] 186 | commits = [ 187 | "3a496cef2d737f69038630f3c884a159f783bd06", # old commit to add test data 188 | "047e58e40c87f9d19d68c21a533b706616ab1ef2", # old commit to add test data 189 | "5345e49e7d63589fc637c2b0c7156bf97e9c72b8", # old commit to add test data 190 | "9c31229cedceedd75e06c381fe7218571a03c26d" # old commit to add test data 191 | ] 192 | 193 | # Additional Examples 194 | 195 | # [[rules]] 196 | # description = "Generic Key" 197 | # regex = '''(?i)key(.{0,6})?(:|=|=>|:=)''' 198 | # entropies = [ 199 | # "4.1-4.3", 200 | # "5.5-6.3", 201 | # ] 202 | # entropyROI = "line" 203 | # filetypes = [".go", ".py", ".c"] 204 | # tags = ["key"] 205 | # severity = "8" 206 | # 207 | # 208 | # [[rules]] 209 | # description = "Generic Key" 210 | # regex = '''(?i)key(.{0,6})?(:|=|=>|:=)''' 211 | # entropies = ["4.1-4.3"] 212 | # filetypes = [".gee"] 213 | # entropyROI = "line" 214 | # tags = ["key"] 215 | # severity = "medium" 216 | 217 | # [[rules]] 218 | # description = "Any pem file" 219 | # filetypes = [".key"] 220 | # tags = ["pem"] 221 | # severity = "high" 222 | -------------------------------------------------------------------------------- /.kateproject: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Slosilo" 3 | , "files": [ { "git": 1 } ] 4 | } 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # v3.0.1 2 | 3 | * The symmetric cipher class now encrypts and decrypts in a thread-safe manner. 4 | [cyberark/slosilo#31](https://github.com/cyberark/slosilo/pull/31) 5 | 6 | # v3.0.0 7 | 8 | * Transition to Ruby 3. Consuming projects based on Ruby 2 shall use slosilo V2.X.X. 9 | 10 | # v2.2.2 11 | 12 | * Add rake task `slosilo:recalculate_fingerprints` which rehashes the fingerprints in the keystore. 13 | **Note**: After migrating the slosilo keystore, run the above rake task to ensure the fingerprints are correctly hashed. 14 | 15 | # v2.2.1 16 | 17 | * Use SHA256 algorithm instead of MD5 for public key fingerprints. 18 | 19 | # v2.1.1 20 | 21 | * Add support for JWT-formatted tokens, with arbitrary expiration. 22 | 23 | # v2.0.1 24 | 25 | * Fixes a bug that occurs when signing tokens containing Unicode data 26 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | For general contribution and community guidelines, please see the [community repo](https://github.com/cyberark/community). 4 | 5 | ## Contributing Workflow 6 | 7 | 1. [Fork the project](https://help.github.com/en/github/getting-started-with-github/fork-a-repo) 8 | 2. [Clone your fork](https://help.github.com/en/github/creating-cloning-and-archiving-repositories/cloning-a-repository) 9 | 3. Make local changes to your fork by editing files 10 | 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) 11 | 4. [Push your local changes to the remote server](https://help.github.com/en/github/using-git/pushing-commits-to-a-remote-repository) 12 | 5. [Create new Pull Request](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request-from-a-fork) 13 | 14 | From here your pull request will be reviewed and once you've responded to all 15 | feedback it will be merged into the project. Congratulations, you're a 16 | contributor! 17 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in slosilo.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /Jenkinsfile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env groovy 2 | 3 | pipeline { 4 | agent { label 'executor-v2' } 5 | 6 | triggers { 7 | cron(getDailyCronString()) 8 | } 9 | 10 | options { 11 | timestamps() 12 | buildDiscarder(logRotator(daysToKeepStr: '30')) 13 | } 14 | 15 | stages { 16 | stage('Test') { 17 | parallel { 18 | stage('Run tests on EE') { 19 | agent { label 'executor-v2-rhel-ee' } 20 | steps { 21 | sh './test.sh' 22 | } 23 | post { always { 24 | stash name: 'eeTestResults', includes: 'spec/reports/*.xml', allowEmpty:true 25 | }} 26 | } 27 | 28 | stage('Run tests') { 29 | steps { 30 | sh './test.sh' 31 | } 32 | } 33 | } 34 | } 35 | 36 | stage('Publish to RubyGems') { 37 | agent { label 'executor-v2' } 38 | when { 39 | allOf { 40 | branch 'master' 41 | expression { 42 | boolean publish = false 43 | 44 | try { 45 | timeout(time: 5, unit: 'MINUTES') { 46 | input(message: 'Publish to RubyGems?') 47 | publish = true 48 | } 49 | } catch (final ignore) { 50 | publish = false 51 | } 52 | 53 | return publish 54 | } 55 | } 56 | } 57 | 58 | steps { 59 | checkout scm 60 | sh './publish-rubygem.sh' 61 | deleteDir() 62 | } 63 | } 64 | } 65 | 66 | post { 67 | always { 68 | dir('ee-results'){ 69 | unstash 'eeTestResults' 70 | } 71 | junit 'spec/reports/*.xml, ee-results/spec/reports/*.xml' 72 | cobertura coberturaReportFile: 'spec/coverage/coverage.xml' 73 | sh 'cp spec/coverage/coverage.xml cobertura.xml' 74 | ccCoverage("cobertura", "github.com/cyberark/slosilo") 75 | 76 | cleanupAndNotify(currentBuild.currentResult) 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 CyberArk Software Ltd. All rights reserved. 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Slosilo 2 | 3 | Slosilo is providing a ruby interface to some cryptographic primitives: 4 | - symmetric encryption, 5 | - a mixin for easy encryption of object attributes, 6 | - asymmetric encryption and signing, 7 | - a keystore in a postgres sequel db -- it allows easy storage and retrieval of keys, 8 | - a keystore in files. 9 | 10 | ## Installation 11 | 12 | Add this line to your application's Gemfile: 13 | 14 | gem 'slosilo' 15 | 16 | And then execute: 17 | 18 | $ bundle 19 | 20 | ## Compatibility 21 | 22 | Version 3.0 introduced full transition to Ruby 3. 23 | Consumers who use slosilo in Ruby 2 projects, shall use slosilo V2.X.X. 24 | 25 | Version 2.0 introduced new symmetric encryption scheme using AES-256-GCM 26 | for authenticated encryption. It allows you to provide AAD on all symmetric 27 | encryption primitives. It's also **NOT COMPATIBLE** with CBC used in version <2. 28 | 29 | This means you'll have to migrate all your existing data. There's no easy way to 30 | do this currently provided; it's recommended to create a database migration and 31 | put relevant code fragments in it directly. (This will also have the benefit of making 32 | the migration self-contained.) 33 | 34 | Since symmetric encryption is used in processing asymetrically encrypted messages, 35 | this incompatibility extends to those too. 36 | 37 | ## Usage 38 | 39 | ### Symmetric encryption 40 | 41 | ```ruby 42 | sym = Slosilo::Symmetric.new 43 | key = sym.random_key 44 | # additional authenticated data 45 | message_id = "message 001" 46 | ciphertext = sym.encrypt "secret message", key: key, aad: message_id 47 | ``` 48 | 49 | ```ruby 50 | sym = Slosilo::Symmetric.new 51 | message = sym.decrypt ciphertext, key: key, aad: message_id 52 | ``` 53 | 54 | ### Encryption mixin 55 | 56 | ```ruby 57 | require 'slosilo' 58 | 59 | class Foo 60 | attr_accessor :foo 61 | attr_encrypted :foo, aad: :id 62 | 63 | def raw_foo 64 | @foo 65 | end 66 | 67 | def id 68 | "unique record id" 69 | end 70 | end 71 | 72 | Slosilo::encryption_key = Slosilo::Symmetric.new.random_key 73 | 74 | obj = Foo.new 75 | obj.foo = "bar" 76 | 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" 77 | obj.foo # => "bar" 78 | ``` 79 | 80 | You can safely use it in ie. ActiveRecord::Base or Sequel::Model subclasses. 81 | 82 | ### Asymmetric encryption and signing 83 | 84 | ```ruby 85 | private_key = Slosilo::Key.new 86 | public_key = private_key.public 87 | ``` 88 | 89 | #### Key dumping 90 | ```ruby 91 | k = public_key.to_s # => "-----BEGIN PUBLIC KEY----- ... 92 | (Slosilo::Key.new k) == public_key # => true 93 | ``` 94 | 95 | #### Encryption 96 | 97 | ```ruby 98 | encrypted = public_key.encrypt_message "eagle one sees many clouds" 99 | # => "\xA3\x1A\xD2\xFC\xB0 ... 100 | 101 | public_key.decrypt_message encrypted 102 | # => OpenSSL::PKey::RSAError: private key needed. 103 | 104 | private_key.decrypt_message encrypted 105 | # => "eagle one sees many clouds" 106 | ``` 107 | 108 | #### Signing 109 | 110 | ```ruby 111 | token = private_key.signed_token "missile launch not authorized" 112 | # => {"data"=>"missile launch not authorized", "timestamp"=>"2014-10-13 12:41:25 UTC", "signature"=>"bSImk...DzV3o", "key"=>"455f7ac42d2d483f750b4c380761821d"} 113 | 114 | public_key.token_valid? token # => true 115 | 116 | token["data"] = "missile launch authorized" 117 | public_key.token_valid? token # => false 118 | ``` 119 | 120 | ### Keystore 121 | 122 | ```ruby 123 | Slosilo::encryption_key = ENV['SLOSILO_KEY'] 124 | Slosilo.adapter = Slosilo::Adapters::FileAdapter.new "~/.keys" 125 | 126 | Slosilo[:own] = Slosilo::Key.new 127 | Slosilo[:their] = Slosilo::Key.new File.read("foo.pem") 128 | 129 | msg = Slosilo[:their].encrypt_message 'bar' 130 | p Slosilo[:own].signed_token msg 131 | ``` 132 | 133 | ### Keystore in database 134 | 135 | Add a migration to create the necessary table: 136 | 137 | require 'slosilo/adapters/sequel_adapter/migration' 138 | 139 | Remember to migrate your database 140 | 141 | $ rake db:migrate 142 | 143 | Then 144 | ```ruby 145 | Slosilo.adapter = Slosilo::Adapters::SequelAdapter.new 146 | ``` 147 | 148 | ## Contributing 149 | 150 | We welcome contributions of all kinds to this repository. For instructions on 151 | how to get started and descriptions of our development workflows, please see our 152 | [contributing guide](CONTRIBUTING.md). 153 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | require "bundler/gem_tasks" 3 | 4 | begin 5 | require 'rspec/core/rake_task' 6 | RSpec::Core::RakeTask.new(:spec) 7 | rescue LoadError 8 | $stderr.puts "RSpec Rake tasks not available in environment #{ENV['RACK_ENV']}" 9 | end 10 | 11 | task :jenkins do 12 | require 'ci/reporter/rake/rspec' 13 | Rake::Task["ci:setup:rspec"].invoke 14 | Rake::Task["spec"].invoke 15 | end 16 | 17 | task :default => :spec 18 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policies and Procedures 2 | 3 | This document outlines security procedures and general policies for the CyberArk Conjur 4 | suite of tools and products. 5 | 6 | * [Reporting a Bug](#reporting-a-bug) 7 | * [Disclosure Policy](#disclosure-policy) 8 | * [Comments on this Policy](#comments-on-this-policy) 9 | 10 | ## Reporting a Bug 11 | 12 | The CyberArk Conjur team and community take all security bugs in the Conjur suite seriously. 13 | Thank you for improving the security of the Conjur suite. We appreciate your efforts and 14 | responsible disclosure and will make every effort to acknowledge your 15 | contributions. 16 | 17 | Report security bugs by emailing the lead maintainers at security@conjur.org. 18 | 19 | The maintainers will acknowledge your email within 2 business days. Subsequently, we will 20 | send a more detailed response within 2 business days of our acknowledgement indicating 21 | the next steps in handling your report. After the initial reply to your report, the security 22 | team will endeavor to keep you informed of the progress towards a fix and full 23 | announcement, and may ask for additional information or guidance. 24 | 25 | Report security bugs in third-party modules to the person or team maintaining 26 | the module. 27 | 28 | ## Disclosure Policy 29 | 30 | When the security team receives a security bug report, they will assign it to a 31 | primary handler. This person will coordinate the fix and release process, 32 | involving the following steps: 33 | 34 | * Confirm the problem and determine the affected versions. 35 | * Audit code to find any potential similar problems. 36 | * Prepare fixes for all releases still under maintenance. These fixes will be 37 | released as fast as possible. 38 | 39 | ## Comments on this Policy 40 | 41 | If you have suggestions on how this process could be improved please submit a 42 | pull request. 43 | -------------------------------------------------------------------------------- /dev/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM ruby 2 | 3 | COPY ./ /src/ 4 | 5 | WORKDIR /src 6 | 7 | RUN bundle 8 | -------------------------------------------------------------------------------- /dev/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | dev: 4 | build: 5 | context: .. 6 | dockerfile: dev/Dockerfile.dev 7 | volumes: 8 | - ../:/src 9 | -------------------------------------------------------------------------------- /lib/slosilo.rb: -------------------------------------------------------------------------------- 1 | require "slosilo/jwt" 2 | require "slosilo/version" 3 | require "slosilo/keystore" 4 | require "slosilo/symmetric" 5 | require "slosilo/attr_encrypted" 6 | require "slosilo/random" 7 | require "slosilo/errors" 8 | 9 | if defined? Sequel 10 | require 'slosilo/adapters/sequel_adapter' 11 | Slosilo::adapter = Slosilo::Adapters::SequelAdapter.new 12 | end 13 | Dir[File.join(File.dirname(__FILE__), 'tasks/*.rake')].each { |ext| load ext } if defined?(Rake) 14 | -------------------------------------------------------------------------------- /lib/slosilo/adapters/abstract_adapter.rb: -------------------------------------------------------------------------------- 1 | require 'slosilo/attr_encrypted' 2 | 3 | module Slosilo 4 | module Adapters 5 | class AbstractAdapter 6 | def get_key id 7 | raise NotImplementedError 8 | end 9 | 10 | def get_by_fingerprint fp 11 | raise NotImplementedError 12 | end 13 | 14 | def put_key id, key 15 | raise NotImplementedError 16 | end 17 | 18 | def each 19 | raise NotImplementedError 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/slosilo/adapters/file_adapter.rb: -------------------------------------------------------------------------------- 1 | require 'slosilo/adapters/abstract_adapter' 2 | 3 | module Slosilo 4 | module Adapters 5 | class FileAdapter < AbstractAdapter 6 | attr_reader :dir 7 | 8 | def initialize(dir) 9 | @dir = dir 10 | @keys = {} 11 | @fingerprints = {} 12 | Dir[File.join(@dir, "*.key")].each do |f| 13 | key = Slosilo::EncryptedAttributes.decrypt File.read(f) 14 | id = File.basename(f, '.key') 15 | key = @keys[id] = Slosilo::Key.new(key) 16 | @fingerprints[key.fingerprint] = id 17 | end 18 | end 19 | 20 | def put_key id, value 21 | raise "id should not contain a period" if id.index('.') 22 | fname = File.join(dir, "#{id}.key") 23 | File.write(fname, Slosilo::EncryptedAttributes.encrypt(value.to_der)) 24 | File.chmod(0400, fname) 25 | @keys[id] = value 26 | end 27 | 28 | def get_key id 29 | @keys[id] 30 | end 31 | 32 | def get_by_fingerprint fp 33 | id = @fingerprints[fp] 34 | [@keys[id], id] 35 | end 36 | 37 | def each(&block) 38 | @keys.each(&block) 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/slosilo/adapters/memory_adapter.rb: -------------------------------------------------------------------------------- 1 | require 'slosilo/adapters/abstract_adapter' 2 | 3 | module Slosilo 4 | module Adapters 5 | class MemoryAdapter < AbstractAdapter 6 | def initialize 7 | @keys = {} 8 | @fingerprints = {} 9 | end 10 | 11 | def put_key id, key 12 | key = Slosilo::Key.new(key) if key.is_a?(String) 13 | @keys[id] = key 14 | @fingerprints[key.fingerprint] = id 15 | end 16 | 17 | def get_key id 18 | @keys[id] 19 | end 20 | 21 | def get_by_fingerprint fp 22 | id = @fingerprints[fp] 23 | [@keys[id], id] 24 | end 25 | 26 | def each(&block) 27 | @keys.each(&block) 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/slosilo/adapters/mock_adapter.rb: -------------------------------------------------------------------------------- 1 | module Slosilo 2 | module Adapters 3 | class MockAdapter < Hash 4 | def initialize 5 | @fp = {} 6 | end 7 | 8 | def put_key id, key 9 | @fp[key.fingerprint] = id 10 | self[id] = key 11 | end 12 | 13 | alias :get_key :[] 14 | 15 | def get_by_fingerprint fp 16 | id = @fp[fp] 17 | [self[id], id] 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/slosilo/adapters/sequel_adapter.rb: -------------------------------------------------------------------------------- 1 | require 'slosilo/adapters/abstract_adapter' 2 | 3 | module Slosilo 4 | module Adapters 5 | class SequelAdapter < AbstractAdapter 6 | def model 7 | @model ||= create_model 8 | end 9 | 10 | def secure? 11 | !Slosilo.encryption_key.nil? 12 | end 13 | 14 | def create_model 15 | model = Sequel::Model(:slosilo_keystore) 16 | model.unrestrict_primary_key 17 | model.attr_encrypted(:key, aad: :id) if secure? 18 | model 19 | end 20 | 21 | def put_key id, value 22 | fail Error::InsecureKeyStorage unless secure? || !value.private? 23 | 24 | attrs = { id: id, key: value.to_der } 25 | attrs[:fingerprint] = value.fingerprint if fingerprint_in_db? 26 | model.create attrs 27 | end 28 | 29 | def get_key id 30 | stored = model[id] 31 | return nil unless stored 32 | Slosilo::Key.new stored.key 33 | end 34 | 35 | def get_by_fingerprint fp 36 | if fingerprint_in_db? 37 | stored = model[fingerprint: fp] 38 | return nil unless stored 39 | [Slosilo::Key.new(stored.key), stored.id] 40 | else 41 | warn "Please migrate to a new database schema using rake slosilo:migrate for efficient fingerprint lookups" 42 | find_by_fingerprint fp 43 | end 44 | end 45 | 46 | def each 47 | model.each do |m| 48 | yield m.id, Slosilo::Key.new(m.key) 49 | end 50 | end 51 | 52 | def recalculate_fingerprints 53 | # Use a transaction to ensure that all fingerprints are updated together. If any update fails, 54 | # we want to rollback all updates. 55 | model.db.transaction do 56 | model.each do |m| 57 | m.update fingerprint: Slosilo::Key.new(m.key).fingerprint 58 | end 59 | end 60 | end 61 | 62 | 63 | def migrate! 64 | unless fingerprint_in_db? 65 | model.db.transaction do 66 | model.db.alter_table :slosilo_keystore do 67 | add_column :fingerprint, String 68 | end 69 | 70 | # reload the schema 71 | model.set_dataset model.dataset 72 | 73 | recalculate_fingerprints 74 | 75 | model.db.alter_table :slosilo_keystore do 76 | set_column_not_null :fingerprint 77 | add_unique_constraint :fingerprint 78 | end 79 | end 80 | end 81 | end 82 | 83 | private 84 | 85 | def fingerprint_in_db? 86 | model.columns.include? :fingerprint 87 | end 88 | 89 | def find_by_fingerprint fp 90 | each do |id, k| 91 | return [k, id] if k.fingerprint == fp 92 | end 93 | end 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/slosilo/adapters/sequel_adapter/migration.rb: -------------------------------------------------------------------------------- 1 | require 'sequel' 2 | 3 | module Slosilo 4 | module Adapters::SequelAdapter::Migration 5 | # The default name of the table to hold the keys 6 | DEFAULT_KEYSTORE_TABLE = :slosilo_keystore 7 | 8 | # Sets up default keystore table name 9 | def self.extended(db) 10 | db.keystore_table ||= DEFAULT_KEYSTORE_TABLE 11 | end 12 | 13 | # Keystore table name. If changing this do it immediately after loading the extension. 14 | attr_accessor :keystore_table 15 | 16 | # Create the table for holding keys 17 | def create_keystore_table 18 | # docs say to not use create_table? in migration; 19 | # but we really want this to be robust in case there are any previous installs 20 | # and we can't use table_exists? because it rolls back 21 | create_table? keystore_table do 22 | String :id, primary_key: true 23 | bytea :key, null: false 24 | String :fingerprint, unique: true, null: false 25 | end 26 | end 27 | 28 | # Drop the table 29 | def drop_keystore_table 30 | drop_table keystore_table 31 | end 32 | end 33 | 34 | module Extension 35 | def slosilo_keystore 36 | extend Slosilo::Adapters::SequelAdapter::Migration 37 | end 38 | end 39 | 40 | Sequel::Database.send :include, Extension 41 | end 42 | 43 | Sequel.migration do 44 | up do 45 | slosilo_keystore 46 | create_keystore_table 47 | end 48 | down do 49 | slosilo_keystore 50 | drop_keystore_table 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/slosilo/attr_encrypted.rb: -------------------------------------------------------------------------------- 1 | require 'slosilo/symmetric' 2 | 3 | module Slosilo 4 | # we don't trust the database to keep all backups safe from the prying eyes 5 | # so we encrypt sensitive attributes before storing them 6 | module EncryptedAttributes 7 | module ClassMethods 8 | 9 | # @param options [Hash] 10 | # @option :aad [#to_proc, #to_s] Provide additional authenticated data for 11 | # encryption. This should be something unique to the instance having 12 | # this attribute, such as a primary key; this will ensure that an attacker can't swap 13 | # values around -- trying to decrypt value with a different auth data will fail. 14 | # This means you have to be able to recover it in order to decrypt attributes. 15 | # The following values are accepted: 16 | # 17 | # * Something proc-ish: will be called with self each time auth data is needed. 18 | # * Something stringish: will be to_s-d and used for all instances as auth data. 19 | # Note that this will only prevent swapping in data using another string. 20 | # 21 | # The recommended way to use this option is to pass a proc-ish that identifies the record. 22 | # Note the proc-ish can be a simple method name; for example in case of a Sequel::Model: 23 | # attr_encrypted :secret, aad: :pk 24 | def attr_encrypted *a 25 | options = a.last.is_a?(Hash) ? a.pop : {} 26 | aad = options[:aad] 27 | # note nil.to_s is "", which is exactly the right thing 28 | auth_data = aad.respond_to?(:to_proc) ? aad.to_proc : proc{ |_| aad.to_s } 29 | 30 | # In ruby 3 .arity for #proc returns both 1 and 2, depends on internal #proc 31 | # This method is also being called with aad which is string, in such case the arity is 1 32 | raise ":aad proc must take two arguments" unless (auth_data.arity.abs == 2 || auth_data.arity.abs == 1) 33 | 34 | # push a module onto the inheritance hierarchy 35 | # this allows calling super in classes 36 | include(accessors = Module.new) 37 | accessors.module_eval do 38 | a.each do |attr| 39 | define_method "#{attr}=" do |value| 40 | super(EncryptedAttributes.encrypt(value, aad: auth_data[self])) 41 | end 42 | define_method attr do 43 | EncryptedAttributes.decrypt(super(), aad: auth_data[self]) 44 | end 45 | end 46 | end 47 | end 48 | 49 | end 50 | 51 | def self.included base 52 | base.extend ClassMethods 53 | end 54 | 55 | class << self 56 | def encrypt value, opts={} 57 | return nil unless value 58 | cipher.encrypt value, key: key, aad: opts[:aad] 59 | end 60 | 61 | def decrypt ctxt, opts={} 62 | return nil unless ctxt 63 | cipher.decrypt ctxt, key: key, aad: opts[:aad] 64 | end 65 | 66 | def key 67 | Slosilo::encryption_key || (raise "Please set Slosilo::encryption_key") 68 | end 69 | 70 | def cipher 71 | @cipher ||= Slosilo::Symmetric.new 72 | end 73 | end 74 | end 75 | 76 | class << self 77 | attr_writer :encryption_key 78 | 79 | def encryption_key 80 | @encryption_key 81 | end 82 | end 83 | end 84 | 85 | Object.send :include, Slosilo::EncryptedAttributes 86 | -------------------------------------------------------------------------------- /lib/slosilo/errors.rb: -------------------------------------------------------------------------------- 1 | module Slosilo 2 | class Error < RuntimeError 3 | # An error thrown when attempting to store a private key in an unecrypted 4 | # storage. Set Slosilo.encryption_key to secure the storage or make sure 5 | # to store just the public keys (using Key#public). 6 | class InsecureKeyStorage < Error 7 | def initialize msg = "can't store a private key in a plaintext storage" 8 | super 9 | end 10 | end 11 | 12 | class TokenValidationError < Error 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/slosilo/jwt.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | 3 | module Slosilo 4 | # A JWT-formatted Slosilo token. 5 | # @note This is not intended to be a general-purpose JWT implementation. 6 | class JWT 7 | # Create a new unsigned token with the given claims. 8 | # @param claims [#to_h] claims to embed in this token. 9 | def initialize claims = {} 10 | @claims = JSONHash[claims] 11 | end 12 | 13 | # Parse a token in compact representation 14 | def self.parse_compact raw 15 | load *raw.split('.', 3).map(&Base64.method(:urlsafe_decode64)) 16 | end 17 | 18 | # Parse a token in JSON representation. 19 | # @note only single signature is currently supported. 20 | def self.parse_json raw 21 | raw = JSON.load raw unless raw.respond_to? :to_h 22 | parts = raw.to_h.values_at(*%w(protected payload signature)) 23 | fail ArgumentError, "input not a complete JWT" unless parts.all? 24 | load *parts.map(&Base64.method(:urlsafe_decode64)) 25 | end 26 | 27 | # Add a signature. 28 | # @note currently only a single signature is handled; 29 | # the token will be frozen after this operation. 30 | def add_signature header, &sign 31 | @claims = canonicalize_claims.freeze 32 | @header = JSONHash[header].freeze 33 | @signature = sign[string_to_sign].freeze 34 | freeze 35 | end 36 | 37 | def string_to_sign 38 | [header, claims].map(&method(:encode)).join '.' 39 | end 40 | 41 | # Returns the JSON serialization of this JWT. 42 | def to_json *a 43 | { 44 | protected: encode(header), 45 | payload: encode(claims), 46 | signature: encode(signature) 47 | }.to_json *a 48 | end 49 | 50 | # Returns the compact serialization of this JWT. 51 | def to_s 52 | [header, claims, signature].map(&method(:encode)).join('.') 53 | end 54 | 55 | attr_accessor :claims, :header, :signature 56 | 57 | private 58 | 59 | # Create a JWT token object from existing header, payload, and signature strings. 60 | # @param header [#to_s] URLbase64-encoded representation of the protected header 61 | # @param payload [#to_s] URLbase64-encoded representation of the token payload 62 | # @param signature [#to_s] URLbase64-encoded representation of the signature 63 | def self.load header, payload, signature 64 | self.new(JSONHash.load payload).tap do |token| 65 | token.header = JSONHash.load header 66 | token.signature = signature.to_s.freeze 67 | token.freeze 68 | end 69 | end 70 | 71 | def canonicalize_claims 72 | claims[:iat] = Time.now unless claims.include? :iat 73 | claims[:iat] = claims[:iat].to_time.to_i 74 | claims[:exp] = claims[:exp].to_time.to_i if claims.include? :exp 75 | JSONHash[claims.to_a] 76 | end 77 | 78 | # Convenience method to make the above code clearer. 79 | # Converts to string and urlbase64-encodes. 80 | def encode s 81 | Base64.urlsafe_encode64 s.to_s 82 | end 83 | 84 | # a hash with a possibly frozen JSON stringification 85 | class JSONHash < Hash 86 | def to_s 87 | @repr || to_json 88 | end 89 | 90 | def freeze 91 | @repr = to_json.freeze 92 | super 93 | end 94 | 95 | def self.load raw 96 | self[JSON.load raw.to_s].tap do |h| 97 | h.send :repr=, raw 98 | end 99 | end 100 | 101 | private 102 | 103 | def repr= raw 104 | @repr = raw.freeze 105 | freeze 106 | end 107 | end 108 | end 109 | 110 | # Try to convert by detecting token representation and parsing 111 | def self.JWT raw 112 | if raw.is_a? JWT 113 | raw 114 | elsif raw.respond_to?(:to_h) || raw =~ /\A\s*\{/ 115 | JWT.parse_json raw 116 | else 117 | JWT.parse_compact raw 118 | end 119 | rescue 120 | raise ArgumentError, "invalid value for JWT(): #{raw.inspect}" 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /lib/slosilo/key.rb: -------------------------------------------------------------------------------- 1 | require 'openssl' 2 | require 'json' 3 | require 'base64' 4 | require 'time' 5 | 6 | require 'slosilo/errors' 7 | 8 | module Slosilo 9 | class Key 10 | def initialize raw_key = nil 11 | @key = if raw_key.is_a? OpenSSL::PKey::RSA 12 | raw_key 13 | elsif !raw_key.nil? 14 | OpenSSL::PKey.read raw_key 15 | else 16 | OpenSSL::PKey::RSA.new 2048 17 | end 18 | rescue OpenSSL::PKey::PKeyError => e 19 | # old openssl versions used to report ArgumentError 20 | # which arguably makes more sense here, so reraise as that 21 | raise ArgumentError, e, e.backtrace 22 | end 23 | 24 | attr_reader :key 25 | 26 | def cipher 27 | @cipher ||= Slosilo::Symmetric.new 28 | end 29 | 30 | def encrypt plaintext 31 | key = cipher.random_key 32 | ctxt = cipher.encrypt plaintext, key: key 33 | key = @key.public_encrypt key 34 | [ctxt, key] 35 | end 36 | 37 | def encrypt_message plaintext 38 | c, k = encrypt plaintext 39 | k + c 40 | end 41 | 42 | def decrypt ciphertext, skey 43 | key = @key.private_decrypt skey 44 | cipher.decrypt ciphertext, key: key 45 | end 46 | 47 | def decrypt_message ciphertext 48 | k, c = ciphertext.unpack("A256A*") 49 | decrypt c, k 50 | end 51 | 52 | def to_s 53 | @key.public_key.to_pem 54 | end 55 | 56 | def to_der 57 | @to_der ||= @key.to_der 58 | end 59 | 60 | def sign value 61 | sign_string(stringify value) 62 | end 63 | 64 | SIGNATURE_LEN = 256 65 | 66 | def verify_signature data, signature 67 | signature, salt = signature.unpack("a#{SIGNATURE_LEN}a*") 68 | key.public_decrypt(signature) == hash_function.digest(salt + stringify(data)) 69 | rescue 70 | false 71 | end 72 | 73 | # create a new timestamped and signed token carrying data 74 | def signed_token data 75 | token = { "data" => data, "timestamp" => Time.new.utc.to_s } 76 | token["signature"] = Base64::urlsafe_encode64(sign token) 77 | token["key"] = fingerprint 78 | token 79 | end 80 | 81 | JWT_ALGORITHM = 'conjur.org/slosilo/v2'.freeze 82 | 83 | # Issue a JWT with the given claims. 84 | # `iat` (issued at) claim is automatically added. 85 | # Other interesting claims you can give are: 86 | # - `sub` - token subject, for example a user name; 87 | # - `exp` - expiration time (absolute); 88 | # - `cidr` (Conjur extension) - array of CIDR masks that are accepted to 89 | # make requests that bear this token 90 | def issue_jwt claims 91 | token = Slosilo::JWT.new claims 92 | token.add_signature \ 93 | alg: JWT_ALGORITHM, 94 | kid: fingerprint, 95 | &method(:sign) 96 | token.freeze 97 | end 98 | 99 | DEFAULT_EXPIRATION = 8 * 60 100 | 101 | def token_valid? token, expiry = DEFAULT_EXPIRATION 102 | return jwt_valid? token if token.respond_to? :header 103 | token = token.clone 104 | expected_key = token.delete "key" 105 | return false if (expected_key and (expected_key != fingerprint)) 106 | signature = Base64::urlsafe_decode64(token.delete "signature") 107 | (Time.parse(token["timestamp"]) + expiry > Time.now) && verify_signature(token, signature) 108 | end 109 | 110 | # Validate a JWT. 111 | # 112 | # Convenience method calling #validate_jwt and returning false if an 113 | # exception is raised. 114 | # 115 | # @param token [JWT] pre-parsed token to verify 116 | # @return [Boolean] 117 | def jwt_valid? token 118 | validate_jwt token 119 | true 120 | rescue 121 | false 122 | end 123 | 124 | # Validate a JWT. 125 | # 126 | # First checks whether algorithm is 'conjur.org/slosilo/v2' and the key id 127 | # matches this key's fingerprint. Then verifies if the token is not expired, 128 | # as indicated by the `exp` claim; in its absence tokens are assumed to 129 | # expire in `iat` + 8 minutes. 130 | # 131 | # If those checks pass, finally the signature is verified. 132 | # 133 | # @raises TokenValidationError if any of the checks fail. 134 | # 135 | # @note It's the responsibility of the caller to examine other claims 136 | # included in the token; consideration needs to be given to handling 137 | # unrecognized claims. 138 | # 139 | # @param token [JWT] pre-parsed token to verify 140 | def validate_jwt token 141 | def err msg 142 | raise Error::TokenValidationError, msg, caller 143 | end 144 | 145 | header = token.header 146 | err 'unrecognized algorithm' unless header['alg'] == JWT_ALGORITHM 147 | err 'mismatched key' if (kid = header['kid']) && kid != fingerprint 148 | iat = Time.at token.claims['iat'] || err('unknown issuing time') 149 | exp = Time.at token.claims['exp'] || (iat + DEFAULT_EXPIRATION) 150 | err 'token expired' if exp <= Time.now 151 | err 'invalid signature' unless verify_signature token.string_to_sign, token.signature 152 | true 153 | end 154 | 155 | def sign_string value 156 | salt = shake_salt 157 | key.private_encrypt(hash_function.digest(salt + value)) + salt 158 | end 159 | 160 | def fingerprint 161 | @fingerprint ||= OpenSSL::Digest::SHA256.hexdigest key.public_key.to_der 162 | end 163 | 164 | def == other 165 | to_der == other.to_der 166 | end 167 | 168 | alias_method :eql?, :== 169 | 170 | def hash 171 | to_der.hash 172 | end 173 | 174 | # return a new key with just the public part of this 175 | def public 176 | Key.new(@key.public_key) 177 | end 178 | 179 | # checks if the keypair contains a private key 180 | def private? 181 | @key.private? 182 | end 183 | 184 | private 185 | 186 | # Note that this is currently somewhat shallow stringification -- 187 | # to implement originating tokens we may need to make it deeper. 188 | def stringify value 189 | string = case value 190 | when Hash 191 | value.to_a.sort.to_json 192 | when String 193 | value 194 | else 195 | value.to_json 196 | end 197 | 198 | # Make sure that the string is ascii_8bit (i.e. raw bytes), and represents 199 | # the utf-8 encoding of the string. This accomplishes two things: it normalizes 200 | # the representation of the string at the byte level (so we don't have an error if 201 | # one username is submitted as ISO-whatever, and the next as UTF-16), and it prevents 202 | # an incompatible encoding error when we concatenate it with the salt. 203 | if string.encoding != Encoding::ASCII_8BIT 204 | string.encode(Encoding::UTF_8).force_encoding(Encoding::ASCII_8BIT) 205 | else 206 | string 207 | end 208 | end 209 | 210 | def shake_salt 211 | Slosilo::Random::salt 212 | end 213 | 214 | def hash_function 215 | @hash_function ||= OpenSSL::Digest::SHA256 216 | end 217 | end 218 | end 219 | -------------------------------------------------------------------------------- /lib/slosilo/keystore.rb: -------------------------------------------------------------------------------- 1 | require 'slosilo/key' 2 | 3 | module Slosilo 4 | class Keystore 5 | def adapter 6 | Slosilo::adapter or raise "No Slosilo adapter is configured or available" 7 | end 8 | 9 | def put id, key 10 | id = id.to_s 11 | fail ArgumentError, "id can't be empty" if id.empty? 12 | adapter.put_key id, key 13 | end 14 | 15 | def get opts 16 | id, fingerprint = opts.is_a?(Hash) ? [nil, opts[:fingerprint]] : [opts, nil] 17 | if id 18 | key = adapter.get_key(id.to_s) 19 | elsif fingerprint 20 | key, _ = get_by_fingerprint(fingerprint) 21 | end 22 | key 23 | end 24 | 25 | def get_by_fingerprint fingerprint 26 | adapter.get_by_fingerprint fingerprint 27 | end 28 | 29 | def each &_ 30 | adapter.each { |k, v| yield k, v } 31 | end 32 | 33 | def any? &block 34 | each do |_, k| 35 | return true if yield k 36 | end 37 | return false 38 | end 39 | end 40 | 41 | class << self 42 | def []= id, value 43 | keystore.put id, value 44 | end 45 | 46 | def [] id 47 | keystore.get id 48 | end 49 | 50 | def each(&block) 51 | keystore.each(&block) 52 | end 53 | 54 | def sign object 55 | self[:own].sign object 56 | end 57 | 58 | def token_valid? token 59 | keystore.any? { |k| k.token_valid? token } 60 | end 61 | 62 | # Looks up the signer by public key fingerprint and checks the validity 63 | # of the signature. If the token is JWT, exp and/or iat claims are also 64 | # verified; the caller is responsible for validating any other claims. 65 | def token_signer token 66 | begin 67 | # see if maybe it's a JWT 68 | token = JWT token 69 | fingerprint = token.header['kid'] 70 | rescue ArgumentError 71 | fingerprint = token['key'] 72 | end 73 | 74 | key, id = keystore.get_by_fingerprint fingerprint 75 | if key && key.token_valid?(token) 76 | return id 77 | else 78 | return nil 79 | end 80 | end 81 | 82 | attr_accessor :adapter 83 | 84 | private 85 | def keystore 86 | @keystore ||= Keystore.new 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/slosilo/random.rb: -------------------------------------------------------------------------------- 1 | require 'openssl' 2 | 3 | module Slosilo 4 | module Random 5 | class << self 6 | def salt 7 | OpenSSL::Random::random_bytes 32 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/slosilo/symmetric.rb: -------------------------------------------------------------------------------- 1 | module Slosilo 2 | class Symmetric 3 | VERSION_MAGIC = 'G' 4 | TAG_LENGTH = 16 5 | 6 | def initialize 7 | @cipher = OpenSSL::Cipher.new 'aes-256-gcm' # NB: has to be lower case for whatever reason. 8 | @cipher_mutex = Mutex.new 9 | end 10 | 11 | # This lets us do a final sanity check in migrations from older encryption versions 12 | def cipher_name 13 | @cipher.name 14 | end 15 | 16 | def encrypt plaintext, opts = {} 17 | # All of these operations in OpenSSL must occur atomically, so we 18 | # synchronize their access to make this step thread-safe. 19 | @cipher_mutex.synchronize do 20 | @cipher.reset 21 | @cipher.encrypt 22 | @cipher.key = (opts[:key] or raise("missing :key option")) 23 | @cipher.iv = iv = random_iv 24 | @cipher.auth_data = opts[:aad] || "" # Nothing good happens if you set this to nil, or don't set it at all 25 | ctext = @cipher.update(plaintext) + @cipher.final 26 | tag = @cipher.auth_tag(TAG_LENGTH) 27 | "#{VERSION_MAGIC}#{tag}#{iv}#{ctext}" 28 | end 29 | end 30 | 31 | def decrypt ciphertext, opts = {} 32 | version, tag, iv, ctext = unpack ciphertext 33 | 34 | raise "Invalid version magic: expected #{VERSION_MAGIC} but was #{version}" unless version == VERSION_MAGIC 35 | 36 | # All of these operations in OpenSSL must occur atomically, so we 37 | # synchronize their access to make this step thread-safe. 38 | @cipher_mutex.synchronize do 39 | @cipher.reset 40 | @cipher.decrypt 41 | @cipher.key = opts[:key] 42 | @cipher.iv = iv 43 | @cipher.auth_tag = tag 44 | @cipher.auth_data = opts[:aad] || "" 45 | @cipher.update(ctext) + @cipher.final 46 | end 47 | end 48 | 49 | def random_iv 50 | @cipher.random_iv 51 | end 52 | 53 | def random_key 54 | @cipher.random_key 55 | end 56 | 57 | private 58 | # return tag, iv, ctext 59 | def unpack msg 60 | msg.unpack "aa#{TAG_LENGTH}a#{@cipher.iv_len}a*" 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/slosilo/version.rb: -------------------------------------------------------------------------------- 1 | module Slosilo 2 | VERSION = "3.0.1" 3 | end 4 | -------------------------------------------------------------------------------- /lib/tasks/slosilo.rake: -------------------------------------------------------------------------------- 1 | namespace :slosilo do 2 | desc "Dump a public key" 3 | task :dump, [:name] => :environment do |t, args| 4 | args.with_defaults(:name => :own) 5 | puts Slosilo[args[:name]] 6 | end 7 | 8 | desc "Enroll a key" 9 | task :enroll, [:name] => :environment do |t, args| 10 | key = Slosilo::Key.new STDIN.read 11 | Slosilo[args[:name]] = key 12 | puts key 13 | end 14 | 15 | desc "Generate a key pair" 16 | task :generate, [:name] => :environment do |t, args| 17 | args.with_defaults(:name => :own) 18 | key = Slosilo::Key.new 19 | Slosilo[args[:name]] = key 20 | puts key 21 | end 22 | 23 | desc "Migrate to a new database schema" 24 | task :migrate => :environment do |t| 25 | Slosilo.adapter.migrate! 26 | end 27 | 28 | desc "Recalculate fingerprints in keystore" 29 | task :recalculate_fingerprints => :environment do |t| 30 | Slosilo.adapter.recalculate_fingerprints 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /publish-rubygem.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | docker pull registry.tld/conjurinc/publish-rubygem 4 | 5 | git clean -fxd 6 | 7 | summon --yaml "RUBYGEMS_API_KEY: !var rubygems/api-key" \ 8 | docker run --rm --env-file @SUMMONENVFILE -v "$(pwd)":/opt/src \ 9 | registry.tld/conjurinc/publish-rubygem slosilo 10 | 11 | git clean -fxd 12 | -------------------------------------------------------------------------------- /slosilo.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | begin 3 | require File.expand_path('../lib/slosilo/version', __FILE__) 4 | rescue LoadError 5 | # so that bundle can be run without the app code 6 | module Slosilo 7 | VERSION = '0.0.0' 8 | end 9 | end 10 | 11 | Gem::Specification.new do |gem| 12 | gem.authors = ["Rafa\305\202 Rzepecki"] 13 | gem.email = ["divided.mind@gmail.com"] 14 | gem.description = %q{This gem provides an easy way of storing and retrieving encryption keys in the database.} 15 | gem.summary = %q{Store SSL keys in a database} 16 | gem.homepage = "" 17 | gem.license = "MIT" 18 | 19 | gem.files = `git ls-files`.split($\) 20 | gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) } 21 | gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) 22 | gem.name = "slosilo" 23 | gem.require_paths = ["lib"] 24 | gem.version = Slosilo::VERSION 25 | gem.required_ruby_version = '>= 3.0.0' 26 | 27 | gem.add_development_dependency 'rake' 28 | gem.add_development_dependency 'rspec', '~> 3.0' 29 | gem.add_development_dependency 'ci_reporter_rspec' 30 | gem.add_development_dependency 'simplecov' 31 | gem.add_development_dependency 'simplecov-cobertura' 32 | gem.add_development_dependency 'io-grab', '~> 0.0.1' 33 | gem.add_development_dependency 'sequel' # for sequel tests 34 | gem.add_development_dependency 'sqlite3' # for sequel tests 35 | gem.add_development_dependency 'activesupport' # for convenience in specs 36 | end 37 | -------------------------------------------------------------------------------- /spec/encrypted_attributes_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'slosilo/attr_encrypted' 3 | 4 | describe Slosilo::EncryptedAttributes do 5 | before(:all) do 6 | Slosilo::encryption_key = OpenSSL::Cipher.new("aes-256-gcm").random_key 7 | end 8 | 9 | let(:aad) { proc{ |_| "hithere" } } 10 | 11 | let(:base){ 12 | Class.new do 13 | attr_accessor :normal_ivar,:with_aad 14 | def stupid_ivar 15 | side_effect! 16 | @_explicit 17 | end 18 | def stupid_ivar= e 19 | side_effect! 20 | @_explicit = e 21 | end 22 | def side_effect! 23 | 24 | end 25 | end 26 | } 27 | 28 | let(:sub){ 29 | Class.new(base) do 30 | attr_encrypted :normal_ivar, :stupid_ivar 31 | end 32 | } 33 | 34 | subject{ sub.new } 35 | 36 | context "when setting a normal ivar" do 37 | let(:value){ "some value" } 38 | it "stores an encrypted value in the ivar" do 39 | subject.normal_ivar = value 40 | expect(subject.instance_variable_get(:"@normal_ivar")).to_not eq(value) 41 | end 42 | 43 | it "recovers the value set" do 44 | subject.normal_ivar = value 45 | expect(subject.normal_ivar).to eq(value) 46 | end 47 | end 48 | 49 | context "when setting an attribute with an implementation" do 50 | it "calls the base class method" do 51 | expect(subject).to receive_messages(:side_effect! => nil) 52 | subject.stupid_ivar = "hi" 53 | expect(subject.stupid_ivar).to eq("hi") 54 | end 55 | end 56 | 57 | context "when given an :aad option" do 58 | 59 | let(:cipher){ Slosilo::EncryptedAttributes.cipher } 60 | let(:key){ Slosilo::EncryptedAttributes.key} 61 | context "that is a string" do 62 | let(:aad){ "hello there" } 63 | before{ sub.attr_encrypted :with_aad, aad: aad } 64 | it "encrypts the value with the given string for auth data" do 65 | expect(cipher).to receive(:encrypt).with("hello", key: key, aad: aad) 66 | subject.with_aad = "hello" 67 | end 68 | 69 | it "decrypts the encrypted value" do 70 | subject.with_aad = "foo" 71 | expect(subject.with_aad).to eq("foo") 72 | end 73 | end 74 | 75 | context "that is nil" do 76 | let(:aad){ nil } 77 | before{ sub.attr_encrypted :with_aad, aad: aad } 78 | it "encrypts the value with an empty string for auth data" do 79 | expect(cipher).to receive(:encrypt).with("hello",key: key, aad: "").and_call_original 80 | subject.with_aad = "hello" 81 | end 82 | 83 | it "decrypts the encrypted value" do 84 | subject.with_aad = "hello" 85 | expect(subject.with_aad).to eq("hello") 86 | end 87 | end 88 | 89 | context "that is a proc" do 90 | let(:aad){ 91 | proc{ |o| "x" } 92 | } 93 | 94 | before{ sub.attr_encrypted :with_aad, aad: aad } 95 | 96 | it "calls the proc with the object being encrypted" do 97 | expect(aad).to receive(:[]).with(subject).and_call_original 98 | subject.with_aad = "hi" 99 | end 100 | 101 | it "encrypts the value with the string returned for auth data" do 102 | expect(cipher).to receive(:encrypt).with("hello", key: key, aad: aad[subject]).and_call_original 103 | subject.with_aad = "hello" 104 | end 105 | it "decrypts the encrypted value" do 106 | subject.with_aad = "hello" 107 | expect(subject.with_aad).to eq("hello") 108 | end 109 | end 110 | 111 | end 112 | 113 | 114 | end 115 | -------------------------------------------------------------------------------- /spec/file_adapter_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'tmpdir' 3 | 4 | require 'slosilo/adapters/file_adapter' 5 | 6 | describe Slosilo::Adapters::FileAdapter do 7 | include_context "with example key" 8 | 9 | let(:dir) { Dir.mktmpdir } 10 | let(:adapter) { Slosilo::Adapters::FileAdapter.new dir } 11 | subject { adapter } 12 | 13 | describe "#get_key" do 14 | context "when given key does not exist" do 15 | it "returns nil" do 16 | expect(subject.get_key(:whatever)).not_to be 17 | end 18 | end 19 | end 20 | 21 | describe "#put_key" do 22 | context "unacceptable id" do 23 | let(:id) { "foo.bar" } 24 | it "isn't accepted" do 25 | expect { subject.put_key id, key }.to raise_error /id should not contain a period/ 26 | end 27 | end 28 | context "acceptable id" do 29 | let(:id) { "id" } 30 | let(:key_encrypted) { "encrypted key" } 31 | let(:fname) { "#{dir}/#{id}.key" } 32 | it "creates the key" do 33 | expect(Slosilo::EncryptedAttributes).to receive(:encrypt).with(key.to_der).and_return key_encrypted 34 | expect(File).to receive(:write).with(fname, key_encrypted) 35 | expect(File).to receive(:chmod).with(0400, fname) 36 | subject.put_key id, key 37 | expect(subject.instance_variable_get("@keys")[id]).to eq(key) 38 | end 39 | end 40 | end 41 | 42 | describe "#each" do 43 | before { adapter.instance_variable_set("@keys", one: :onek, two: :twok) } 44 | 45 | it "iterates over each key" do 46 | results = [] 47 | adapter.each { |id,k| results << { id => k } } 48 | expect(results).to eq([ { one: :onek}, {two: :twok } ]) 49 | end 50 | end 51 | 52 | context 'with real key store' do 53 | let(:id) { 'some id' } 54 | 55 | before do 56 | Slosilo::encryption_key = Slosilo::Symmetric.new.random_key 57 | pre_adapter = Slosilo::Adapters::FileAdapter.new dir 58 | pre_adapter.put_key(id, key) 59 | end 60 | 61 | describe '#get_key' do 62 | it "loads and decrypts the key" do 63 | expect(adapter.get_key(id)).to eq(key) 64 | end 65 | end 66 | 67 | describe '#get_by_fingerprint' do 68 | it "can look up a key by a fingerprint" do 69 | expect(adapter.get_by_fingerprint(key_fingerprint)).to eq([key, id]) 70 | end 71 | end 72 | 73 | describe '#each' do 74 | it "enumerates the keys" do 75 | results = [] 76 | adapter.each { |id,k| results << { id => k } } 77 | expect(results).to eq([ { id => key } ]) 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /spec/jwt_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | # (Mostly) integration tests for JWT token format 4 | describe Slosilo::Key do 5 | include_context "with example key" 6 | 7 | describe '#issue_jwt' do 8 | it 'issues an JWT token with given claims' do 9 | allow(Time).to receive(:now) { DateTime.parse('2014-06-04 23:22:32 -0400').to_time } 10 | 11 | tok = key.issue_jwt sub: 'host/example', cidr: %w(fec0::/64) 12 | 13 | expect(tok).to be_frozen 14 | 15 | expect(tok.header).to eq \ 16 | alg: 'conjur.org/slosilo/v2', 17 | kid: key_fingerprint 18 | expect(tok.claims).to eq \ 19 | iat: 1401938552, 20 | sub: 'host/example', 21 | cidr: ['fec0::/64'] 22 | 23 | expect(key.verify_signature tok.string_to_sign, tok.signature).to be_truthy 24 | end 25 | end 26 | end 27 | 28 | describe Slosilo::JWT do 29 | context "with a signed token" do 30 | let(:signature) { 'very signed, such alg' } 31 | subject(:token) { Slosilo::JWT.new test: "token" } 32 | before do 33 | allow(Time).to receive(:now) { DateTime.parse('2014-06-04 23:22:32 -0400').to_time } 34 | token.add_signature(alg: 'test-sig') { signature } 35 | end 36 | 37 | it 'allows conversion to JSON representation with #to_json' do 38 | json = JSON.load token.to_json 39 | expect(JSON.load Base64.urlsafe_decode64 json['protected']).to eq \ 40 | 'alg' => 'test-sig' 41 | expect(JSON.load Base64.urlsafe_decode64 json['payload']).to eq \ 42 | 'iat' => 1401938552, 'test' => 'token' 43 | expect(Base64.urlsafe_decode64 json['signature']).to eq signature 44 | end 45 | 46 | it 'allows conversion to compact representation with #to_s' do 47 | h, c, s = token.to_s.split '.' 48 | expect(JSON.load Base64.urlsafe_decode64 h).to eq \ 49 | 'alg' => 'test-sig' 50 | expect(JSON.load Base64.urlsafe_decode64 c).to eq \ 51 | 'iat' => 1401938552, 'test' => 'token' 52 | expect(Base64.urlsafe_decode64 s).to eq signature 53 | end 54 | end 55 | 56 | describe '#to_json' do 57 | it "passes any parameters" do 58 | token = Slosilo::JWT.new 59 | allow(token).to receive_messages \ 60 | header: :header, 61 | claims: :claims, 62 | signature: :signature 63 | expect_any_instance_of(Hash).to receive(:to_json).with :testing 64 | expect(token.to_json :testing) 65 | end 66 | end 67 | 68 | describe '()' do 69 | include_context "with example key" 70 | 71 | it 'understands both serializations' do 72 | [COMPACT_TOKEN, JSON_TOKEN].each do |token| 73 | token = Slosilo::JWT token 74 | expect(token.header).to eq \ 75 | 'typ' => 'JWT', 76 | 'alg' => 'conjur.org/slosilo/v2', 77 | 'kid' => key_fingerprint 78 | expect(token.claims).to eq \ 79 | 'sub' => 'host/example', 80 | 'iat' => 1401938552, 81 | 'exp' => 1401938552 + 60*60, 82 | 'cidr' => ['fec0::/64'] 83 | expect(key.verify_signature token.string_to_sign, token.signature).to be_truthy 84 | end 85 | end 86 | 87 | it 'is a noop if already parsed' do 88 | token = Slosilo::JWT COMPACT_TOKEN 89 | expect(Slosilo::JWT token).to eq token 90 | end 91 | 92 | it 'raises ArgumentError on failure to convert' do 93 | expect { Slosilo::JWT "foo bar" }.to raise_error ArgumentError 94 | expect { Slosilo::JWT elite: 31337 }.to raise_error ArgumentError 95 | expect { Slosilo::JWT "foo.bar.xyzzy" }.to raise_error ArgumentError 96 | end 97 | end 98 | 99 | COMPACT_TOKEN = "eyJ0eXAiOiJKV1QiLCJhbGciOiJjb25qdXIub3JnL3Nsb3NpbG8vdjIiLCJraWQiOiIxMDdiZGI4NTAxYzQxOWZhZDJmZGIyMGI0NjdkNGQwYTYyYTE2YTk4YzM1ZjJkYTBlYjNiMWZmOTI5Nzk1YWQ5In0=.eyJzdWIiOiJob3N0L2V4YW1wbGUiLCJjaWRyIjpbImZlYzA6Oi82NCJdLCJleHAiOjE0MDE5NDIxNTIsImlhdCI6MTQwMTkzODU1Mn0=.qSxy6gx0DbiIc-Wz_vZhBsYi1SCkHhzxfMGPnnG6MTqjlzy7ntmlU2H92GKGoqCRo6AaNLA_C3hA42PeEarV5nMoTj8XJO_kwhrt2Db2OX4u83VS0_enoztWEZG5s45V0Lv71lVR530j4LD-hpqhm_f4VuISkeH84u0zX7s1zKOlniuZP-abCAHh0htTnrVz9wKG0VywkCUmWYyNNqC2h8PRf64SvCWcQ6VleHpjO-ms8OeTw4ZzRbzKMi0mL6eTmQlbT3PeBArUaS0pNJPg9zdDQaL2XDOofvQmj6Yy_8RA4eCt9HEfTYEdriVqK-_9QCspbGzFVn9GTWf51MRi5dngV9ItsDoG9ktDtqFuMttv7TcqjftsIHZXZsAZ175E".freeze 100 | 101 | JSON_TOKEN = "{\"protected\":\"eyJ0eXAiOiJKV1QiLCJhbGciOiJjb25qdXIub3JnL3Nsb3NpbG8vdjIiLCJraWQiOiIxMDdiZGI4NTAxYzQxOWZhZDJmZGIyMGI0NjdkNGQwYTYyYTE2YTk4YzM1ZjJkYTBlYjNiMWZmOTI5Nzk1YWQ5In0=\",\"payload\":\"eyJzdWIiOiJob3N0L2V4YW1wbGUiLCJjaWRyIjpbImZlYzA6Oi82NCJdLCJleHAiOjE0MDE5NDIxNTIsImlhdCI6MTQwMTkzODU1Mn0=\",\"signature\":\"qSxy6gx0DbiIc-Wz_vZhBsYi1SCkHhzxfMGPnnG6MTqjlzy7ntmlU2H92GKGoqCRo6AaNLA_C3hA42PeEarV5nMoTj8XJO_kwhrt2Db2OX4u83VS0_enoztWEZG5s45V0Lv71lVR530j4LD-hpqhm_f4VuISkeH84u0zX7s1zKOlniuZP-abCAHh0htTnrVz9wKG0VywkCUmWYyNNqC2h8PRf64SvCWcQ6VleHpjO-ms8OeTw4ZzRbzKMi0mL6eTmQlbT3PeBArUaS0pNJPg9zdDQaL2XDOofvQmj6Yy_8RA4eCt9HEfTYEdriVqK-_9QCspbGzFVn9GTWf51MRi5dngV9ItsDoG9ktDtqFuMttv7TcqjftsIHZXZsAZ175E\"}".freeze 102 | end 103 | -------------------------------------------------------------------------------- /spec/key_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | require 'active_support' 4 | require 'active_support/core_ext/numeric/time' 5 | 6 | describe Slosilo::Key do 7 | include_context "with example key" 8 | 9 | subject { key } 10 | 11 | describe '#to_der' do 12 | subject { super().to_der } 13 | it { is_expected.to eq(rsa.to_der) } 14 | end 15 | 16 | describe '#to_s' do 17 | subject { super().to_s } 18 | it { is_expected.to eq(rsa.public_key.to_pem) } 19 | end 20 | 21 | describe '#fingerprint' do 22 | subject { super().fingerprint } 23 | it { is_expected.to eq(key_fingerprint) } 24 | end 25 | it { is_expected.to be_private } 26 | 27 | context "with identical key" do 28 | let(:other) { Slosilo::Key.new rsa.to_der } 29 | it "is equal" do 30 | expect(subject).to eq(other) 31 | end 32 | 33 | it "is eql?" do 34 | expect(subject.eql?(other)).to be_truthy 35 | end 36 | 37 | it "has equal hash" do 38 | expect(subject.hash).to eq(other.hash) 39 | end 40 | end 41 | 42 | context "with a different key" do 43 | let(:other) { Slosilo::Key.new another_rsa } 44 | it "is not equal" do 45 | expect(subject).not_to eq(other) 46 | end 47 | 48 | it "is not eql?" do 49 | expect(subject.eql?(other)).not_to be_truthy 50 | end 51 | 52 | it "has different hash" do 53 | expect(subject.hash).not_to eq(other.hash) 54 | end 55 | end 56 | 57 | describe '#public' do 58 | it "returns a key with just the public half" do 59 | pkey = subject.public 60 | expect(pkey).to be_a(Slosilo::Key) 61 | expect(pkey).to_not be_private 62 | expect(pkey.key).to_not be_private 63 | expect(pkey.to_der).to eq(rsa.public_key.to_der) 64 | end 65 | end 66 | 67 | let(:plaintext) { 'quick brown fox jumped over the lazy dog' } 68 | describe '#encrypt' do 69 | it "generates a symmetric encryption key and encrypts the plaintext with the public key" do 70 | ctxt, skey = subject.encrypt plaintext 71 | pskey = rsa.private_decrypt skey 72 | expect(Slosilo::Symmetric.new.decrypt(ctxt, key: pskey)).to eq(plaintext) 73 | end 74 | end 75 | 76 | describe '#encrypt_message' do 77 | it "#encrypts a message and then returns the result as a single string" do 78 | expect(subject).to receive(:encrypt).with(plaintext).and_return ['fake ciphertext', 'fake key'] 79 | expect(subject.encrypt_message(plaintext)).to eq('fake keyfake ciphertext') 80 | end 81 | end 82 | 83 | 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') } 84 | 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') } 85 | describe '#decrypt' do 86 | it "decrypts the symmetric key and then uses it to decrypt the ciphertext" do 87 | expect(subject.decrypt(ciphertext, skey)).to eq(plaintext) 88 | end 89 | end 90 | 91 | describe '#decrypt_message' do 92 | it "splits the message into key and rest, then #decrypts it" do 93 | expect(subject).to receive(:decrypt).with(ciphertext, skey).and_return plaintext 94 | expect(subject.decrypt_message(skey + ciphertext)).to eq(plaintext) 95 | end 96 | end 97 | 98 | describe '#initialize' do 99 | context "when no argument given" do 100 | subject { Slosilo::Key.new } 101 | let (:rsa) { double "key" } 102 | it "generates a new key pair" do 103 | expect(OpenSSL::PKey::RSA).to receive(:new).with(2048).and_return(rsa) 104 | expect(subject.key).to eq(rsa) 105 | end 106 | end 107 | context "when given an armored key" do 108 | subject { Slosilo::Key.new rsa.to_der } 109 | 110 | describe '#to_der' do 111 | subject { super().to_der } 112 | it { is_expected.to eq(rsa.to_der) } 113 | end 114 | end 115 | context "when given a key instance" do 116 | subject { Slosilo::Key.new rsa } 117 | 118 | describe '#to_der' do 119 | subject { super().to_der } 120 | it { is_expected.to eq(rsa.to_der) } 121 | end 122 | end 123 | context "when given something else" do 124 | subject { Slosilo::Key.new "foo" } 125 | it "fails early" do 126 | expect { subject }.to raise_error ArgumentError 127 | end 128 | end 129 | end 130 | 131 | describe "#sign" do 132 | context "when given a hash" do 133 | it "converts to a sorted array and signs that" do 134 | expect(key).to receive(:sign_string).with '[["a",3],["b",42]]' 135 | key.sign b: 42, a: 3 136 | end 137 | end 138 | context "when given an array" do 139 | it "signs a JSON representation instead" do 140 | expect(key).to receive(:sign_string).with '[2,[42,2]]' 141 | key.sign [2, [42, 2]] 142 | end 143 | end 144 | context "when given a string" do 145 | 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} } 158 | 159 | it "converts the value to raw bytes before signing it" do 160 | expect(key).to receive(:sign_string).with("[[\"data\",\"#{encoded}\"]]").and_call_original 161 | key.sign hash 162 | end 163 | end 164 | end 165 | 166 | describe "#signed_token" do 167 | let(:time) { Time.new(2012,1,1,1,1,1,0) } 168 | let(:data) { { "foo" => :bar } } 169 | let(:token_to_sign) { { "data" => data, "timestamp" => "2012-01-01 01:01:01 UTC" } } 170 | let(:signature) { "signature" } 171 | let(:salt) { 'a pinch of salt' } 172 | 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") } 173 | let(:expected_token) { token_to_sign.merge "signature" => expected_signature, "key" => key_fingerprint } 174 | before do 175 | allow(key).to receive_messages shake_salt: salt 176 | allow(Time).to receive_messages new: time 177 | end 178 | subject { key.signed_token data } 179 | it { is_expected.to eq(expected_token) } 180 | end 181 | 182 | describe "#validate_jwt" do 183 | let(:token) do 184 | instance_double Slosilo::JWT, 185 | header: { 'alg' => 'conjur.org/slosilo/v2' }, 186 | claims: { 'iat' => Time.now.to_i }, 187 | string_to_sign: double("string to sign"), 188 | signature: double("signature") 189 | end 190 | 191 | before do 192 | allow(key).to receive(:verify_signature).with(token.string_to_sign, token.signature) { true } 193 | end 194 | 195 | it "verifies the signature" do 196 | expect { key.validate_jwt token }.not_to raise_error 197 | end 198 | 199 | it "rejects unknown algorithm" do 200 | token.header['alg'] = 'HS256' # we're not supporting standard algorithms 201 | expect { key.validate_jwt token }.to raise_error /algorithm/ 202 | end 203 | 204 | it "rejects bad signature" do 205 | allow(key).to receive(:verify_signature).with(token.string_to_sign, token.signature) { false } 206 | expect { key.validate_jwt token }.to raise_error /signature/ 207 | end 208 | 209 | it "rejects expired token" do 210 | token.claims['exp'] = 1.hour.ago.to_i 211 | expect { key.validate_jwt token }.to raise_error /expired/ 212 | end 213 | 214 | it "accepts unexpired token with implicit expiration" do 215 | token.claims['iat'] = 5.minutes.ago 216 | expect { key.validate_jwt token }.to_not raise_error 217 | end 218 | 219 | it "rejects token expired with implicit expiration" do 220 | token.claims['iat'] = 10.minutes.ago.to_i 221 | expect { key.validate_jwt token }.to raise_error /expired/ 222 | end 223 | end 224 | 225 | describe "#token_valid?" do 226 | let(:data) { { "foo" => :bar } } 227 | 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") } 228 | let(:token) { { "data" => data, "timestamp" => "2012-01-01 01:01:01 UTC", "signature" => signature } } 229 | before { allow(Time).to receive_messages now: Time.new(2012,1,1,1,2,1,0) } 230 | subject { key.token_valid? token } 231 | it { is_expected.to be_truthy } 232 | 233 | it "doesn't check signature on the advisory key field" do 234 | expect(key.token_valid?(token.merge "key" => key_fingerprint)).to be_truthy 235 | end 236 | 237 | it "rejects the token if the key field is present and doesn't match" do 238 | expect(key.token_valid?(token.merge "key" => "this is not the key you are looking for")).not_to be_truthy 239 | end 240 | 241 | context "when token is 1 hour old" do 242 | before { allow(Time).to receive_messages now: Time.new(2012,1,1,2,1,1,0) } 243 | it { is_expected.to be_falsey } 244 | context "when timestamp in the token is changed accordingly" do 245 | let(:token) { { "data" => data, "timestamp" => "2012-01-01 02:00:01 UTC", "signature" => signature } } 246 | it { is_expected.to be_falsey } 247 | end 248 | end 249 | context "when the data is changed" do 250 | let(:data) { { "foo" => :baz } } 251 | it { is_expected.to be_falsey } 252 | end 253 | context "when RSA decrypt raises an error" do 254 | before { expect_any_instance_of(OpenSSL::PKey::RSA).to receive(:public_decrypt).and_raise(OpenSSL::PKey::RSAError) } 255 | it { is_expected.to be_falsey } 256 | end 257 | end 258 | end 259 | -------------------------------------------------------------------------------- /spec/keystore_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Slosilo::Keystore do 4 | include_context "with example key" 5 | include_context "with mock adapter" 6 | 7 | describe '#put' do 8 | it "handles Slosilo::Keys" do 9 | subject.put(:test, key) 10 | expect(adapter['test'].to_der).to eq(rsa.to_der) 11 | end 12 | 13 | it "refuses to store a key with a nil id" do 14 | expect { subject.put(nil, key) }.to raise_error(ArgumentError) 15 | end 16 | 17 | it "refuses to store a key with an empty id" do 18 | expect { subject.put('', key) }.to raise_error(ArgumentError) 19 | end 20 | 21 | it "passes the Slosilo key to the adapter" do 22 | expect(adapter).to receive(:put_key).with "test", key 23 | subject.put :test, key 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/random_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Slosilo::Random do 4 | subject { Slosilo::Random } 5 | let(:other_salt) { Slosilo::Random::salt } 6 | 7 | describe '#salt' do 8 | subject { super().salt } 9 | describe '#length' do 10 | subject { super().length } 11 | it { is_expected.to eq(32) } 12 | end 13 | end 14 | 15 | describe '#salt' do 16 | subject { super().salt } 17 | it { is_expected.not_to eq(other_salt) } 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/sequel_adapter_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'sequel' 3 | require 'io/grab' 4 | 5 | require 'slosilo/adapters/sequel_adapter' 6 | 7 | describe Slosilo::Adapters::SequelAdapter do 8 | include_context "with example key" 9 | 10 | let(:model) { double "model" } 11 | before { allow(subject).to receive_messages create_model: model } 12 | 13 | describe "#get_key" do 14 | context "when given key does not exist" do 15 | before { allow(model).to receive_messages :[] => nil } 16 | it "returns nil" do 17 | expect(subject.get_key(:whatever)).not_to be 18 | end 19 | end 20 | 21 | context "when it exists" do 22 | let(:id) { "id" } 23 | before { allow(model).to receive(:[]).with(id).and_return (double "key entry", id: id, key: rsa.to_der) } 24 | it "returns it" do 25 | expect(subject.get_key(id)).to eq(key) 26 | end 27 | end 28 | end 29 | 30 | describe "#put_key" do 31 | let(:id) { "id" } 32 | it "creates the key" do 33 | expect(model).to receive(:create).with(hash_including(:id => id, :key => key.to_der)) 34 | allow(model).to receive_messages columns: [:id, :key] 35 | subject.put_key id, key 36 | end 37 | 38 | it "adds the fingerprint if feasible" do 39 | expect(model).to receive(:create).with(hash_including(:id => id, :key => key.to_der, :fingerprint => key.fingerprint)) 40 | allow(model).to receive_messages columns: [:id, :key, :fingerprint] 41 | subject.put_key id, key 42 | end 43 | end 44 | 45 | let(:adapter) { subject } 46 | describe "#each" do 47 | let(:one) { double("one", id: :one, key: :onek) } 48 | let(:two) { double("two", id: :two, key: :twok) } 49 | before { allow(model).to receive(:each).and_yield(one).and_yield(two) } 50 | 51 | it "iterates over each key" do 52 | results = [] 53 | allow(Slosilo::Key).to receive(:new) {|x|x} 54 | adapter.each { |id,k| results << { id => k } } 55 | expect(results).to eq([ { one: :onek}, {two: :twok } ]) 56 | end 57 | end 58 | 59 | shared_context "database" do 60 | let(:db) { Sequel.sqlite } 61 | before do 62 | allow(subject).to receive(:create_model).and_call_original 63 | Sequel::Model.cache_anonymous_models = false 64 | Sequel::Model.db = db 65 | end 66 | end 67 | 68 | shared_context "encryption key" do 69 | before do 70 | Slosilo.encryption_key = Slosilo::Symmetric.new.random_key 71 | end 72 | end 73 | 74 | context "with old schema" do 75 | include_context "encryption key" 76 | include_context "database" 77 | 78 | before do 79 | db.create_table :slosilo_keystore do 80 | String :id, primary_key: true 81 | bytea :key, null: false 82 | end 83 | subject.put_key 'test', key 84 | end 85 | 86 | context "after migration" do 87 | before { subject.migrate! } 88 | 89 | it "supports look up by id" do 90 | expect(subject.get_key("test")).to eq(key) 91 | end 92 | 93 | it "supports look up by fingerprint, without a warning" do 94 | expect($stderr.grab do 95 | expect(subject.get_by_fingerprint(key.fingerprint)).to eq([key, 'test']) 96 | end).to be_empty 97 | end 98 | end 99 | 100 | it "supports look up by id" do 101 | expect(subject.get_key("test")).to eq(key) 102 | end 103 | 104 | it "supports look up by fingerprint, but issues a warning" do 105 | expect($stderr.grab do 106 | expect(subject.get_by_fingerprint(key.fingerprint)).to eq([key, 'test']) 107 | end).not_to be_empty 108 | end 109 | end 110 | 111 | shared_context "current schema" do 112 | include_context "database" 113 | before do 114 | Sequel.extension :migration 115 | require 'slosilo/adapters/sequel_adapter/migration.rb' 116 | Sequel::Migration.descendants.first.apply db, :up 117 | end 118 | end 119 | 120 | context "with current schema" do 121 | include_context "encryption key" 122 | include_context "current schema" 123 | before do 124 | subject.put_key 'test', key 125 | end 126 | 127 | it "supports look up by id" do 128 | expect(subject.get_key("test")).to eq(key) 129 | end 130 | 131 | it "supports look up by fingerprint" do 132 | expect(subject.get_by_fingerprint(key.fingerprint)).to eq([key, 'test']) 133 | end 134 | end 135 | 136 | context "with an encryption key", :wip do 137 | include_context "encryption key" 138 | include_context "current schema" 139 | 140 | it { is_expected.to be_secure } 141 | 142 | it "saves the keys in encrypted form" do 143 | subject.put_key 'test', key 144 | 145 | expect(db[:slosilo_keystore][id: 'test'][:key]).to_not eq(key.to_der) 146 | expect(subject.get_key 'test').to eq(key) 147 | end 148 | end 149 | 150 | context "without an encryption key", :wip do 151 | before do 152 | Slosilo.encryption_key = nil 153 | end 154 | 155 | include_context "current schema" 156 | 157 | it { is_expected.not_to be_secure } 158 | 159 | it "refuses to store a private key" do 160 | expect { subject.put_key 'test', key }.to raise_error(Slosilo::Error::InsecureKeyStorage) 161 | end 162 | 163 | it "saves the keys in plaintext form" do 164 | pkey = key.public 165 | subject.put_key 'test', pkey 166 | 167 | expect(db[:slosilo_keystore][id: 'test'][:key]).to eq(pkey.to_der) 168 | expect(subject.get_key 'test').to eq(pkey) 169 | end 170 | end 171 | end 172 | -------------------------------------------------------------------------------- /spec/slosilo_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Slosilo do 4 | include_context "with mock adapter" 5 | include_context "with example key" 6 | before { Slosilo['test'] = key } 7 | 8 | describe '[]' do 9 | it "returns a Slosilo::Key" do 10 | expect(Slosilo[:test]).to be_instance_of Slosilo::Key 11 | end 12 | 13 | it "allows looking up by fingerprint" do 14 | expect(Slosilo[fingerprint: key_fingerprint]).to eq(key) 15 | end 16 | 17 | context "when the requested key does not exist" do 18 | it "returns nil instead of creating a new key" do 19 | expect(Slosilo[:aether]).not_to be 20 | end 21 | end 22 | end 23 | 24 | describe '.sign' do 25 | let(:own_key) { double "own key" } 26 | before { allow(Slosilo).to receive(:[]).with(:own).and_return own_key } 27 | let (:argument) { double "thing to sign" } 28 | it "fetches the own key and signs using that" do 29 | expect(own_key).to receive(:sign).with(argument) 30 | Slosilo.sign argument 31 | end 32 | end 33 | 34 | describe '.token_valid?' do 35 | before { allow(adapter['test']).to receive_messages token_valid?: false } 36 | let(:key2) { double "key 2", token_valid?: false } 37 | let(:key3) { double "key 3", token_valid?: false } 38 | before do 39 | adapter[:key2] = key2 40 | adapter[:key3] = key3 41 | end 42 | 43 | let(:token) { double "token" } 44 | subject { Slosilo.token_valid? token } 45 | 46 | context "when no key validates the token" do 47 | before { allow(Slosilo::Key).to receive_messages new: (double "key", token_valid?: false) } 48 | it { is_expected.to be_falsey } 49 | end 50 | 51 | context "when a key validates the token" do 52 | let(:valid_key) { double token_valid?: true } 53 | let(:invalid_key) { double token_valid?: true } 54 | before do 55 | allow(Slosilo::Key).to receive_messages new: invalid_key 56 | adapter[:key2] = valid_key 57 | end 58 | 59 | it { is_expected.to be_truthy } 60 | end 61 | end 62 | 63 | describe '.token_signer' do 64 | 65 | context "when token matches a key" do 66 | let(:token) {{ 'data' => 'foo', 'key' => key.fingerprint, 'signature' => 'XXX' }} 67 | 68 | context "and the signature is valid" do 69 | before { allow(key).to receive(:token_valid?).with(token).and_return true } 70 | 71 | it "returns the key id" do 72 | expect(subject.token_signer(token)).to eq('test') 73 | end 74 | end 75 | 76 | context "and the signature is invalid" do 77 | before { allow(key).to receive(:token_valid?).with(token).and_return false } 78 | 79 | it "returns nil" do 80 | expect(subject.token_signer(token)).not_to be 81 | end 82 | end 83 | end 84 | 85 | context "when token doesn't match a key" do 86 | let(:token) {{ 'data' => 'foo', 'key' => "footprint", 'signature' => 'XXX' }} 87 | it "returns nil" do 88 | expect(subject.token_signer(token)).not_to be 89 | end 90 | end 91 | 92 | context "with JWT token" do 93 | before do 94 | expect(key).to receive(:validate_jwt) do |jwt| 95 | expect(jwt.header).to eq 'kid' => key.fingerprint 96 | expect(jwt.claims).to eq({}) 97 | expect(jwt.signature).to eq 'sig' 98 | end 99 | end 100 | 101 | it "accepts pre-parsed JSON serialization" do 102 | expect(Slosilo.token_signer( 103 | 'protected' => 'eyJraWQiOiIxMDdiZGI4NTAxYzQxOWZhZDJmZGIyMGI0NjdkNGQwYTYyYTE2YTk4YzM1ZjJkYTBlYjNiMWZmOTI5Nzk1YWQ5In0=', 104 | 'payload' => 'e30=', 105 | 'signature' => 'c2ln' 106 | )).to eq 'test' 107 | end 108 | 109 | it "accepts pre-parsed JWT token" do 110 | expect(Slosilo.token_signer(Slosilo::JWT( 111 | 'protected' => 'eyJraWQiOiIxMDdiZGI4NTAxYzQxOWZhZDJmZGIyMGI0NjdkNGQwYTYyYTE2YTk4YzM1ZjJkYTBlYjNiMWZmOTI5Nzk1YWQ5In0=', 112 | 'payload' => 'e30=', 113 | 'signature' => 'c2ln' 114 | ))).to eq 'test' 115 | end 116 | 117 | it "accepts compact serialization" do 118 | expect(Slosilo.token_signer( 119 | 'eyJraWQiOiIxMDdiZGI4NTAxYzQxOWZhZDJmZGIyMGI0NjdkNGQwYTYyYTE2YTk4YzM1ZjJkYTBlYjNiMWZmOTI5Nzk1YWQ5In0=.e30=.c2ln' 120 | )).to eq 'test' 121 | end 122 | end 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "simplecov" 2 | require "simplecov-cobertura" 3 | 4 | SimpleCov.formatter = SimpleCov::Formatter::CoberturaFormatter 5 | SimpleCov.start 6 | 7 | require 'slosilo' 8 | 9 | shared_context "with mock adapter" do 10 | require 'slosilo/adapters/mock_adapter' 11 | 12 | let(:adapter) { Slosilo::Adapters::MockAdapter.new } 13 | before { Slosilo::adapter = adapter } 14 | end 15 | 16 | shared_context "with example key" do 17 | let(:rsa) { OpenSSL::PKey::RSA.new """ 18 | -----BEGIN RSA PRIVATE KEY----- 19 | MIIEpQIBAAKCAQEAtTG/SQhW9QawP+GL6EZ5Al9gscCr7HiRO7MuQqFkaXIJD6+3 20 | prdHRrb0qqjNlGFgDBGAuswZ2AYqhBt7eekup+/vIpI5n04b0w+is3WwZAFco4uP 21 | ojDeM0aY65Ar3Zgra2vWUJXRwBumroZjVBVoLJSgVfwIhwU6ORbS2oJflbtqxpuS 22 | zkPDqS6RwEzI/DHuHTOI26fe+vfuDqGOuSR6iVI16lfvTbWwccpDwU0W9vSlyjjD 23 | LIw0MnoKL3DHyzO66s+oNNRleMvjghQtJk/xg1kRuHReJ5/ygt2zyzdKSLeqU+T+ 24 | TCWw/F65jrFElftexiS+g+lZC467VLCaMe1fJQIDAQABAoIBAQCiNWzXRr4CEQDL 25 | z3Deeehu9U+tEZ1Xzv/FgD0TrUQlGc9+2YIBn+YRKkySUxfnk9zWMP0bPQiN2cdK 26 | CQhbNSNteGCOhHVNZjGGm2K+YceNX6K9Tn1BZ5okMTlI+QIsGMQWIK316omh/58S 27 | coCNj7R45H09PKmtpkJfRU1yDHDhqypjPDpb9/7U5mt3g2BdXYi+1hilfonHoDrC 28 | yy3eRdf7Tlij9O3UeM+Z7pZrKATcvpDkYbNWizDITvKMYy6Ss+ajM5v7lt6QN5LP 29 | MHjwX8Ilrxkxl0jeopr4f94tR7rNDZbLC457j8gns7cUeODtF7pPZqlrlk4KOq8Q 30 | DvEMt2ZpAoGBAOLNUiO1SwRo75Y8ukuMVQev8O8WuzEEGINoM1lQiYlbUw3HmVp3 31 | iUvv58ANmKSzTXpOEZ1L8pZHSp435FrzD2WZmCAoXhNdfAXtmZA7Y46iE6BF4qrr 32 | UegtLPhVgwpO74Y+4w2YwfDknzCOhWE4sxCbukuSvxz2pz1Vm31eFB6jAoGBAMyF 33 | VxfYq9WhmLNsHqR+qfhb1EC5FfpSq23z/o4ryiKqCaWHimVtOO7DL7x2SK3mVNNZ 34 | X8b4+vnJpAQ3nOxeg8fpmBaLAWYRna2AN/CYVIMKYusawhsGAlZZTu2mtJKLiOPS 35 | 8/z5dK55xJWlG5JalUB+n/4vd3WmXiT/XJj3qU+XAoGBALyHzLXeKCPcTvzmMj5G 36 | wxAG0xMMJEMUkoP5hGXEKvBBOAMGXpXzM/Ap1s2w/6g5XDhE2SOWVGtTi9WFxI9N 37 | 6Qid6vUgWUNjvIr4/WQF2jZgyEu8jDVkM8v6cZ1lB+7zuuwvLnLI/r6ObT3h20H7 38 | 7e3qZawYqkEbT94OYZiPMc5dAoGAHmIQtjIyFOKU1NLTGozWo1bBCXx1j2KIpSUC 39 | RAytUsj/9d9U6Ax50L6ecNkBoxP8tgko+V4zqrgR7a51WYgQ+7nwJikwZAFp80SB 40 | CvUWWQFKALNQ8sLJxhouZ4/Ec6DXDUFhjcthUio00iZdGjjqw1IMYq6aiJfWlJh7 41 | IR5pwLECgYEAyjlguks/3cjrHRF+yjonxT4tLuBI/n3TAQUPqmtkJtcwZSAJas/1 42 | c/twlAJ7F6n3ZroF3lgPxMJRRHZl4Z4dJsDatIxVShf3nSGg9Mi5C25obxahbv5/ 43 | Dg1ikwi8GUF4HPZe9DyhXgDhg19wM/qcpjX8bSypsUWHWP+FanhjdWU= 44 | -----END RSA PRIVATE KEY----- 45 | """ } 46 | let (:key) { Slosilo::Key.new rsa.to_der } 47 | let (:key_fingerprint) { "107bdb8501c419fad2fdb20b467d4d0a62a16a98c35f2da0eb3b1ff929795ad9" } 48 | 49 | let (:another_rsa) do 50 | OpenSSL::PKey::RSA.new """ 51 | -----BEGIN RSA PRIVATE KEY----- 52 | MIIEowIBAAKCAQEAryP0uGEIcDFmHDj1MjxbW+eWMeQ1k2FTKI7qx2M3MP9FR3Bz 53 | KjFzGKnAA6QV46K/QtEt+wpWedB/bcikPXY4/vh/b2TEi8Ybw2ztT1oW9le8Djsz 54 | 3sQv5QrHsOXzSIARw4NZYxunxMFKCVC9jA8tXJb16RLgS3wAOMiPADlWIKEmPIX6 55 | +hg2PDgFcrCuL3XAwJ4GKy3Q5BpIFF2j+wRNfjCXDFf1bU9Gy9DND8Y50Khhw/Zn 56 | GYN1Y3AZ3YPzz1SPf08WM663ImYwORjdkA5VlIAMKcmSStNZZUrCOo7DQjNZVD2O 57 | vfGhGUlPqYkmTPnCG2aNP8aJm3IbF+Cb6N6PjwIDAQABAoIBAEaYtr9PlagrsV40 58 | 81kxjR3pptgrhhEHTQ7vNOH0Mz4T16gpQrLCRgOuARE2pgAhDPlw+hjUHPFzQrpN 59 | Ay8nJWhZYHzVYIh67ZwDn1C6HsFjshEGei0UZb3sb3v15O/Xd9GYc4KIlkKwKxjA 60 | K/d18rH8w9kUW8bxj+FTrpjHg9kYkWGjl1WUM4o4dALVVAbbILCHKUIv3wmU5Off 61 | oqBDunItrfVvvc9UOt1SMO15fwuZZpk0B5cjjo6+1NNpIOzqnuu48iI5dQRAIr50 62 | n44U4/Ix4E1p4i/9i5trCeSZRMrVxBruNxFBtCeDU6YW5fXYNBLptndfb83iqSJf 63 | 46myqakCgYEA2MAsbtOcvQv+C7KsRMQih4WqpybV/TRdeC+dZ3flPvSuI8VLJAHp 64 | p2Tp3WXATCwgUWL/iktwWE7WFMn3VvAuMm2ITmAze/Uk71uUS5R+iaGIeRXHgd9J 65 | fyJrIeD63ncWbb23rif2sO6zH4cp9NLS/OopHiRNlRsWEUoGpybxczMCgYEAztrf 66 | mX4oqjqk4af4o4/UHVp3Y9lpcUXRi6dYYECoqv6wS7qCIbJkD4I4P6oTwvk25vbk 67 | p9fwOttuqHC53/rDXVjedNe9VExIe5NhVaug1SyArw/qsafYs0QeDRBkSgCcLfP6 68 | LP4g824Wbv52X33BO0rJbDCICDqGDCOkqB4XcjUCgYBCkcMTxqo85ZIAxb9i31o7 69 | hTIEZEkUmyCZ6QXO4WPnEf7pvY52YKACaVvqQ3Xr7yF93YneT40RkiTt/ZmZeeq2 70 | Ui2q5KDrUT8mxFmnXNQAMTxY8/dyS8Gm6ks8/HwQF0MsMThYpK1/adBZvomER7vF 71 | MaWvPDcXtFnytWmVrMA7QQKBgQDIHpHR4m6e+atIMIPoYR5Z44q7i7tp/ZzTGevy 72 | +rry6wFN0jtRNE9/fYDDftwtdYL7AYKHKu7bUi0FQkFhAi39YhudOJaPNlmtTBEP 73 | m8I2Wh6IvsJUa0jHbbAQ/Xm46kwuXOn8m0LvnuKPMRj+GyBVJ24kf/Mq2suSdO04 74 | RBx0vQKBgFz93G6bSzmFg0BRTqRWEXEIuYkMIZDe48OjeP4pLYH9aERsL/f/8Dyc 75 | X2nOMv/TdLP7mvGnwCt/sQ2626DdiNqimekyBki9J2r6BzBNVmEvnLAcYaQAiQYz 76 | ooQ2FuL0K6ukQfHPjuMswqi41lmVH8gIVqVC+QnImUCrGxH9WXWy 77 | -----END RSA PRIVATE KEY----- 78 | """ 79 | end 80 | 81 | def self.mock_own_key 82 | before { allow(Slosilo).to receive(:[]).with(:own).and_return key } 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /spec/symmetric_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Slosilo::Symmetric do 4 | # TODO transform it to class methods only? 5 | let(:plaintext) { "quick brown fox jumped over the lazy dog" } 6 | let(:auth_data) { "some record id" } 7 | 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") } 8 | let(:iv) { "\xD9\xABn\x01b\xFA\xBD\xC2\xE5\xEA\x01\xAC".force_encoding("ASCII-8BIT") } 9 | 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') } 10 | 11 | describe '#encrypt' do 12 | it "encrypts with AES-256-GCM" do 13 | allow(subject).to receive_messages random_iv: iv 14 | expect(subject.encrypt(plaintext, key: key, aad: auth_data)).to eq(ciphertext) 15 | end 16 | end 17 | 18 | describe '#decrypt' do 19 | 20 | it "doesn't fail when called by multiple threads" do 21 | threads = [] 22 | 23 | begin 24 | # Verify we can successfuly decrypt using many threads without OpenSSL 25 | # errors. 26 | 1000.times do 27 | threads << Thread.new do 28 | 100.times do 29 | expect( 30 | subject.decrypt(ciphertext, key: key, aad: auth_data) 31 | ).to eq(plaintext) 32 | end 33 | end 34 | end 35 | ensure 36 | threads.each(&:join) 37 | end 38 | end 39 | 40 | it "decrypts with AES-256-GCM" do 41 | expect(subject.decrypt(ciphertext, key: key, aad: auth_data)).to eq(plaintext) 42 | end 43 | 44 | 45 | context "when the ciphertext has been messed with" do 46 | let(:ciphertext) { "pwnd!" } # maybe we should do something more realistic like add some padding? 47 | it "raises an exception" do 48 | expect{ subject.decrypt(ciphertext, key: key, aad: auth_data)}.to raise_exception /Invalid version/ 49 | end 50 | context "by adding a trailing 0" do 51 | let(:new_ciphertext){ ciphertext + '\0' } 52 | it "raises an exception" do 53 | expect{ subject.decrypt(new_ciphertext, key: key, aad: auth_data) }.to raise_exception /Invalid version/ 54 | end 55 | end 56 | end 57 | 58 | context "when no auth_data is given" do 59 | let(:auth_data){""} 60 | 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"} 61 | 62 | it "decrypts the message" do 63 | expect(subject.decrypt(ciphertext, key: key, aad: auth_data)).to eq(plaintext) 64 | end 65 | 66 | context "and the ciphertext has been messed with" do 67 | it "raises an exception" do 68 | expect{ subject.decrypt(ciphertext + "\0\0\0", key: key, aad: auth_data)}.to raise_exception OpenSSL::Cipher::CipherError 69 | end 70 | end 71 | end 72 | 73 | context "when the auth data doesn't match" do 74 | let(:auth_data){ "asdf" } 75 | it "raises an exception" do 76 | expect{ subject.decrypt(ciphertext, key: key, aad: auth_data)}.to raise_exception OpenSSL::Cipher::CipherError 77 | end 78 | end 79 | end 80 | 81 | describe '#random_iv' do 82 | it "generates a random iv" do 83 | expect_any_instance_of(OpenSSL::Cipher).to receive(:random_iv).and_return :iv 84 | expect(subject.random_iv).to eq(:iv) 85 | end 86 | end 87 | 88 | describe '#random_key' do 89 | it "generates a random key" do 90 | expect_any_instance_of(OpenSSL::Cipher).to receive(:random_key).and_return :key 91 | expect(subject.random_key).to eq(:key) 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -xe 2 | 3 | iid=slosilo-test-$(date +%s) 4 | 5 | docker build -t $iid -f - . << EOF 6 | FROM ruby:3.0 7 | WORKDIR /app 8 | COPY Gemfile slosilo.gemspec ./ 9 | RUN bundle 10 | COPY . ./ 11 | RUN bundle 12 | EOF 13 | 14 | cidfile=$(mktemp -u) 15 | docker run --cidfile $cidfile -v /app/spec/reports $iid bundle exec rake jenkins || : 16 | 17 | cid=$(cat $cidfile) 18 | 19 | docker cp $cid:/app/spec/reports spec/ 20 | docker cp $cid:/app/coverage spec 21 | 22 | docker rm $cid 23 | 24 | # untag, will use cache next time if available but no junk will be left 25 | docker rmi $iid 26 | 27 | rm $cidfile 28 | --------------------------------------------------------------------------------