require 'yaml' require 'httparty' require 'json' require 'gibberish' require 'digest/md5' require 'AES' require 'machineid' module Onboardbase class << self attr_accessor :env, :is_dev def is_dev @is_dev.nil? ? false : @is_dev end def env @env.nil? end def encrypt(password, iv, cleardata) cipher = OpenSSL::Cipher.new('AES-256-CBC') cipher.encrypt # set cipher to be encryption mode cipher.key = password cipher.iv = iv encrypted = '' encrypted << cipher.update(cleardata) encrypted << cipher.final b64enc(encrypted) end def decrypt(password, iv, secretdata) secretdata = Base64::decode64(secretdata) decipher = OpenSSL::Cipher::Cipher.new('aes-256-cbc') decipher.decrypt decipher.key = password decipher.iv = iv if iv != nil decipher.update(secretdata) + decipher.final end def b64enc(data) Base64.encode64(data).gsub(/\n/, '') end def apiURL return "https://devapi.onboardbase.com/graphql" if self.is_dev "https://api.onboardbase.com/graphql" end def configuration @configuration ||= {} end def initialize super end def config yield self end def getWorkingDirectory Dir.pwd end def getOnboardbaseDir "#{Dir.home}/.onboardbase" end def getFallbackDir "#{self.getOnboardbaseDir}/fallback" end def getProjectFallbackDir project = self.configuration['setup']['project'] "#{self.getFallbackDir}/#{project}" end def getEnvironmentFallbackDir environment = self.configuration['setup']['environment'] "#{self.getProjectFallbackDir}_#{environment}" end def config_exists?(directory) return File.exist?(directory) end def loadConfig configPath = self.getWorkingDirectory + '/onboardbase.yaml' unless self.config_exists?(configPath) puts "Please create onboardbase.yaml in the root of the project at: " + configPath exit 1 end config = YAML.load_file(configPath) if (config['api_key'] == nil) puts "Your onboardbase.yaml file does not have an api_key" exit 1 end if (config['passcode'] == nil) puts "Your onboardbase.yaml file does not have a passcode" exit 1 end @configuration = config end def makeRequest url = self.apiURL headers = { KEY: self.configuration['api_key'], } body = { query: %{ query { generalPublicProjects(filterOptions: { title: "#{self .configuration['setup']['project']}", disableCustomSelect: true }) { list { id title publicEnvironments(filterOptions: { title: "#{self .configuration['setup']['environment']}" }) { list { id key title } } } } } } } response = HTTParty.post(url, headers: headers, body: body) JSON.parse(response.body) end def parseResponse?(response) error = response["errors"] data = response["data"] return data["generalPublicProjects"] if error == nil {:error=> true, :message => error[0]["message"] } end def getProject?(data) project = data["list"][0] return project if project != nil false end def getSecrets?(project) env = project["publicEnvironments"]["list"][0] return JSON.parse(env["key"]) if env != nil false end def bytes_to_key(data, salt, output=48) merged = data + salt key = Digest::MD5.digest(merged) final_key = key while final_key.length < output key = Digest::MD5.digest(key + merged) final_key = final_key + key end final_key[0..output-1] end def aes256_cbc_decrypt(key, data, iv) key = Digest::SHA256.digest(key) if(key.kind_of?(String) && 32 != key.bytesize) iv = Digest::MD5.digest(iv) if(iv.kind_of?(String) && 16 != iv.bytesize) aes = OpenSSL::Cipher.new('AES-256-CBC') aes.decrypt aes.key = key aes.iv = iv aes.update(data) + aes.final end def parseSecrets(secrets) secrets.each_with_index do |secret, i| secret = Base64.decode64(secret) unless secret[0..7] == 'Salted__' puts "Invalid encrypted data" exit(1) end salt = secret[8..15] key_iv = bytes_to_key(self.configuration["passcode"], salt, 48) key = key_iv[0..31] iv = key_iv[32..] parsedSecret = aes256_cbc_decrypt(key, secret[16..], iv) secrets[i] = JSON.parse(parsedSecret) end secrets end def setEnv(secretsHash) secretsHash.keys.sort.each do |key| ENV["#{key}"] = "#{secretsHash[key]}" end # Overried local secrets configSecrets = self.configuration["secrets"] unless configSecrets configSecrets = { "local" => {} } end configSecrets["local"].keys.sort.each do |key| ENV["#{key}"] = "#{configSecrets["local"][key]}" end ENV.to_hash end def hashSecrets?(secretsArr) secretsHash = Hash.new secretsArr.each do |secret| secretsHash["#{secret["key"]}"] = "#{secret["value"]}" end secretsHash end def storeToFallback?(secrets) unless File.directory?(self.getOnboardbaseDir) Dir.new(self.getOnboardbaseDir) end unless File.directory?(self.getFallbackDir) Dir.new(self.getFallbackDir) end password = MachineID.ID? cipher = Gibberish::AES::CBC.new(password) cipher_text = cipher.encrypt(JSON.generate(secrets)) data = cipher_text File.write(self.getEnvironmentFallbackDir, data, nil , mode: 'w') end def readFallback unless File.directory?(self.getOnboardbaseDir) Dir.new(self.getOnboardbaseDir) end unless File.directory?(self.getFallbackDir) Dir.new(self.getFallbackDir) end if !File.exist?(self.getEnvironmentFallbackDir) || File.read(self.getEnvironmentFallbackDir).length <= 0 puts "No valid fallback for #{self .configuration['setup']['project']} project using #{self .configuration['setup']['environment']} environment" return JSON.parse("{}") # Graceful failure, ensure the application continues to run without secrets. end data = File.read(self.getEnvironmentFallbackDir) password = MachineID.ID? cipher = Gibberish::AES::CBC.new(password) decoded_data = cipher.decrypt(data) JSON.parse(decoded_data) end def loadSecrets self.loadConfig response = self.parseResponse?(self.makeRequest) if response[:error] puts "Unable to fetch secrets with the specified api key, reading from fallback file" secrets = self.readFallback else project = self.getProject?(response) projectSecrets = self.getSecrets?(project) parsedSecrets = self.parseSecrets(projectSecrets) secrets = self.hashSecrets?(parsedSecrets) end finalEnvs = self.setEnv(secrets) self.storeToFallback?(finalEnvs) end end end