2016-10-17 20:51:47 +00:00
|
|
|
# encoding: utf-8
|
|
|
|
# frozen_string_literal: true
|
|
|
|
require 'base64'
|
|
|
|
require 'openssl'
|
|
|
|
require 'pathname'
|
|
|
|
require 'set'
|
|
|
|
require 'tempfile'
|
|
|
|
require 'yaml'
|
|
|
|
|
|
|
|
# Notes:
|
|
|
|
#
|
|
|
|
# Generate keys
|
|
|
|
# The initial implementation uses 2048 bit RSA key pairs (public + private).
|
|
|
|
# Public keys must be available for a customer to install and verify an artifact.
|
|
|
|
# Private keys should be stored in a secure location and NOT be distributed.
|
|
|
|
# (They're only for creating artifacts).
|
|
|
|
#
|
|
|
|
#
|
|
|
|
# .IAF file format
|
|
|
|
# .iaf = "Inspec Artifact File", easy to rename if you'd like something more appropriate.
|
|
|
|
# The iaf file wraps a binary artifact with some metadata. The first implementation
|
|
|
|
# looks like this:
|
|
|
|
#
|
|
|
|
# INSPEC-PROFILE-1
|
|
|
|
# name_of_signing_key
|
|
|
|
# algorithm
|
|
|
|
# signature
|
|
|
|
# <empty line>
|
|
|
|
# binary-blob
|
|
|
|
# <eof>
|
|
|
|
#
|
|
|
|
# Let's look at each line:
|
|
|
|
# INSPEC-PROFILE-1:
|
|
|
|
# This is the artifact version descriptor. It should't change unless the
|
|
|
|
# format of the archive changes.
|
|
|
|
#
|
|
|
|
# name_of_signing_key
|
|
|
|
# The name of the public key that can be used to verify an artifact
|
|
|
|
#
|
|
|
|
# algorithm
|
|
|
|
# The digest used to sign, I picked SHA512 to start with.
|
|
|
|
# If we support multiple digests, we'll need to have the verify() method
|
|
|
|
# support each digest.
|
|
|
|
#
|
|
|
|
# signature
|
|
|
|
# The result of passing the binary artifact through the digest algorithm above.
|
|
|
|
# Result is base64 encoded.
|
|
|
|
#
|
|
|
|
# <empty line>
|
|
|
|
# We use an empty line to separate artifact header from artifact body (binary blob).
|
|
|
|
# The artifact body can be anything you like.
|
|
|
|
#
|
|
|
|
# binary-blob
|
|
|
|
# A binary blob, most likely a .tar.gz or tar.xz file. We'll need to pick one and
|
|
|
|
# stick with it as part of the "INSPEC-PROFILE-1" artifact version. If we change block
|
|
|
|
# format, the artifact version descriptor must be incremented, and the sign()
|
|
|
|
# and verify() methods must be updated to support a newer version.
|
|
|
|
#
|
|
|
|
#
|
|
|
|
# Key revocation
|
|
|
|
# This implementation doesn't support key revocation. However, a customer
|
|
|
|
# can remove the public cert file before installation, and artifacts will then
|
|
|
|
# fail verification.
|
|
|
|
#
|
|
|
|
# Key locations
|
|
|
|
# This implementation uses the current working directory to find public and
|
|
|
|
# private keys. We should establish a common key directory (similar to /hab/cache/keys
|
|
|
|
# or ~/.hab/cache/keys in Habitat).
|
|
|
|
#
|
|
|
|
# Extracting artifacts outside of Inspec
|
|
|
|
# As in Habitat, the artifact format for Inspec allows the use of common
|
|
|
|
# Unix tools to read the header and body of an artifact.
|
|
|
|
# To extract the header from a .iaf:
|
|
|
|
# sed '/^$/q' foo.iaf
|
|
|
|
# To extract the raw content from a .iaf:
|
|
|
|
# sed '1,/^$/d' foo.iaf
|
|
|
|
|
|
|
|
module Artifact
|
|
|
|
KEY_BITS=2048
|
|
|
|
KEY_ALG=OpenSSL::PKey::RSA
|
|
|
|
|
|
|
|
INSPEC_PROFILE_VERSION_1='INSPEC-PROFILE-1'.freeze
|
|
|
|
INSPEC_REPORT_VERSION_1='INSPEC-REPORT-1'.freeze
|
|
|
|
|
|
|
|
ARTIFACT_DIGEST=OpenSSL::Digest::SHA512
|
|
|
|
ARTIFACT_DIGEST_NAME='SHA512'.freeze
|
|
|
|
|
|
|
|
VALID_PROFILE_VERSIONS=Set.new [INSPEC_PROFILE_VERSION_1]
|
|
|
|
VALID_PROFILE_DIGESTS=Set.new [ARTIFACT_DIGEST_NAME]
|
|
|
|
|
|
|
|
SIGNED_PROFILE_SUFFIX='iaf'.freeze
|
|
|
|
SIGNED_REPORT_SUFFIX='iar'.freeze
|
|
|
|
|
|
|
|
# rubocop:disable Metrics/ClassLength
|
|
|
|
class CLI < Inspec::BaseCLI
|
|
|
|
namespace 'artifact'
|
|
|
|
|
|
|
|
# TODO: find another solution, once https://github.com/erikhuda/thor/issues/261 is fixed
|
|
|
|
def self.banner(command, _namespace = nil, _subcommand = false)
|
|
|
|
"#{basename} #{subcommand_prefix} #{command.usage}"
|
|
|
|
end
|
|
|
|
|
|
|
|
def self.subcommand_prefix
|
|
|
|
namespace
|
|
|
|
end
|
|
|
|
|
|
|
|
desc 'generate', 'Generate a RSA key pair for signing and verification'
|
|
|
|
option :keyname, type: :string, required: true,
|
|
|
|
desc: 'Desriptive name of key'
|
|
|
|
option :keydir, type: :string, default: './',
|
|
|
|
desc: 'Directory to search for keys'
|
|
|
|
def generate_keys
|
|
|
|
puts 'Generating keys'
|
|
|
|
keygen
|
|
|
|
end
|
|
|
|
|
|
|
|
desc 'sign-profile', 'Create a signed .iaf artifact'
|
|
|
|
option :profile, type: :string, required: true,
|
|
|
|
desc: 'Path to profile directory'
|
|
|
|
option :keyname, type: :string, required: true,
|
|
|
|
desc: 'Desriptive name of key'
|
|
|
|
def sign_profile
|
|
|
|
profile_sign
|
|
|
|
end
|
|
|
|
|
|
|
|
desc 'verify-profile', 'Verify a signed .iaf artifact'
|
|
|
|
option :infile, type: :string, required: true,
|
|
|
|
desc: '.iaf file to verify'
|
|
|
|
def verify_profile
|
|
|
|
profile_verify
|
|
|
|
end
|
|
|
|
|
|
|
|
desc 'install-profile', 'Verify and install a signed .iaf artifact'
|
|
|
|
option :infile, type: :string, required: true,
|
|
|
|
desc: '.iaf file to install'
|
|
|
|
option :destdir, type: :string, required: true,
|
|
|
|
desc: 'Installation directory'
|
|
|
|
def install_profile
|
|
|
|
profile_install
|
|
|
|
end
|
|
|
|
|
|
|
|
private
|
|
|
|
|
|
|
|
def keygen
|
|
|
|
key = KEY_ALG.new KEY_BITS
|
|
|
|
puts 'Generating private key'
|
|
|
|
open "#{options['keyname']}.pem.key", 'w' do |io| io.write key.to_pem end
|
|
|
|
puts 'Generating public key'
|
|
|
|
open "#{options['keyname']}.pem.pub", 'w' do |io| io.write key.public_key.to_pem end
|
|
|
|
end
|
|
|
|
|
|
|
|
def read_profile_metadata(path_to_profile)
|
|
|
|
begin
|
|
|
|
p = Pathname.new(path_to_profile)
|
|
|
|
p = p.join('inspec.yml')
|
|
|
|
if not p.exist?
|
2017-02-08 22:49:16 +00:00
|
|
|
raise "#{path_to_profile} doesn't appear to be a valid Inspec profile"
|
2016-10-17 20:51:47 +00:00
|
|
|
end
|
|
|
|
yaml = YAML.load_file(p.to_s)
|
|
|
|
yaml = yaml.to_hash
|
|
|
|
|
|
|
|
if not yaml.key? 'name'
|
2017-02-08 22:49:16 +00:00
|
|
|
raise 'Profile is invalid, name is not defined'
|
2016-10-17 20:51:47 +00:00
|
|
|
end
|
|
|
|
|
|
|
|
if not yaml.key? 'version'
|
2017-02-08 22:49:16 +00:00
|
|
|
raise 'Profile is invalid, version is not defined'
|
2016-10-17 20:51:47 +00:00
|
|
|
end
|
|
|
|
rescue => e
|
|
|
|
# rewrap it and pass it up to the CLI
|
|
|
|
raise "Error reading Inspec profile metadata: #{e}"
|
|
|
|
end
|
|
|
|
|
|
|
|
yaml
|
|
|
|
end
|
|
|
|
|
|
|
|
def profile_compress(path_to_profile, profile_md, workdir)
|
|
|
|
profile_name = profile_md['name']
|
|
|
|
profile_version = profile_md['version']
|
|
|
|
outfile_name = "#{workdir}/#{profile_name}-#{profile_version}.tar.gz"
|
|
|
|
`tar czf #{outfile_name} -C #{path_to_profile} .`
|
|
|
|
outfile_name
|
|
|
|
end
|
|
|
|
|
|
|
|
def profile_sign
|
|
|
|
Dir.mktmpdir do |workdir|
|
|
|
|
puts "Signing #{options['profile']} with key #{options['keyname']}"
|
|
|
|
path_to_profile = options['profile']
|
|
|
|
profile_md = read_profile_metadata(path_to_profile)
|
|
|
|
artifact_filename = "#{profile_md['name']}-#{profile_md['version']}.#{SIGNED_PROFILE_SUFFIX}"
|
|
|
|
tarfile = profile_compress(path_to_profile, profile_md, workdir)
|
|
|
|
content = IO.binread(tarfile)
|
|
|
|
signing_key = KEY_ALG.new File.read "#{options['keyname']}.pem.key"
|
|
|
|
sha = ARTIFACT_DIGEST.new
|
|
|
|
signature = signing_key.sign sha, content
|
|
|
|
# convert the signature to Base64
|
|
|
|
signature_base64 = Base64.encode64(signature)
|
|
|
|
tar_content = IO.binread(tarfile)
|
|
|
|
File.open(artifact_filename, 'wb') do |f|
|
|
|
|
f.puts(INSPEC_PROFILE_VERSION_1)
|
|
|
|
f.puts(options['keyname'])
|
|
|
|
f.puts(ARTIFACT_DIGEST_NAME)
|
|
|
|
f.puts(signature_base64)
|
|
|
|
f.puts('') # newline separates artifact header with body
|
|
|
|
f.write(tar_content)
|
|
|
|
end
|
|
|
|
puts "Successfully generated #{artifact_filename}"
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def valid_header?(file_alg, file_version, file_keyname)
|
|
|
|
public_keyfile = "#{file_keyname}.pem.pub"
|
|
|
|
puts "Looking for #{public_keyfile} to verify artifact"
|
|
|
|
if not File.exist? public_keyfile
|
2017-02-08 22:49:16 +00:00
|
|
|
raise "Can't find #{public_keyfile}"
|
2016-10-17 20:51:47 +00:00
|
|
|
end
|
|
|
|
|
|
|
|
if not VALID_PROFILE_DIGESTS.member? file_alg
|
2017-02-08 22:49:16 +00:00
|
|
|
raise 'Invalid artifact digest algorithm detected'
|
2016-10-17 20:51:47 +00:00
|
|
|
end
|
|
|
|
|
|
|
|
if not VALID_PROFILE_VERSIONS.member? file_version
|
2017-02-08 22:49:16 +00:00
|
|
|
raise 'Invalid artifact version detected'
|
2016-10-17 20:51:47 +00:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def verify(file_to_verifiy, &content_block)
|
|
|
|
f = File.open(file_to_verifiy, 'r')
|
|
|
|
file_version = f.readline.strip!
|
|
|
|
file_keyname = f.readline.strip!
|
|
|
|
file_alg = f.readline.strip!
|
|
|
|
|
|
|
|
file_sig = ''
|
|
|
|
# the signature is multi-line
|
|
|
|
while (line = f.readline) != "\n"
|
|
|
|
file_sig += line
|
|
|
|
end
|
|
|
|
file_sig.strip!
|
|
|
|
f.close
|
|
|
|
|
|
|
|
valid_header?(file_alg, file_version, file_keyname)
|
|
|
|
|
|
|
|
public_keyfile = "#{file_keyname}.pem.pub"
|
|
|
|
verification_key = KEY_ALG.new File.read public_keyfile
|
|
|
|
|
|
|
|
f = File.open(file_to_verifiy, 'r')
|
|
|
|
while f.readline != "\n" do end
|
|
|
|
content = f.read
|
|
|
|
|
|
|
|
signature = Base64.decode64(file_sig)
|
|
|
|
digest = ARTIFACT_DIGEST.new
|
|
|
|
if verification_key.verify digest, signature, content
|
|
|
|
content_block.yield(content)
|
|
|
|
else
|
|
|
|
puts 'Artifact is invalid'
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def profile_verify
|
|
|
|
file_to_verifiy = options['infile']
|
|
|
|
puts "Verifying #{file_to_verifiy}"
|
|
|
|
verify(file_to_verifiy) do ||
|
|
|
|
puts 'Artifact is valid'
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def profile_install
|
|
|
|
puts 'Installing profile'
|
|
|
|
file_to_verifiy = options['infile']
|
|
|
|
dest_dir = options['destdir']
|
|
|
|
verify(file_to_verifiy) do |content|
|
|
|
|
Dir.mktmpdir do |workdir|
|
|
|
|
tmpfile = Pathname.new(workdir).join('artifact_to_install.tar.gz')
|
|
|
|
File.write(tmpfile, content)
|
|
|
|
puts "Installing to #{dest_dir}"
|
|
|
|
`tar xzf #{tmpfile} -C #{dest_dir}`
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
# register the subcommand to Inspec CLI registry
|
|
|
|
Inspec::Plugins::CLI.add_subcommand(Artifact::CLI, 'artifact', 'artifact SUBCOMMAND ...', 'Sign, verify and install artifacts', {})
|
|
|
|
end
|