mirror of
https://github.com/inspec/inspec
synced 2024-11-27 07:00:39 +00:00
Merge pull request #1228 from chef/dp_signing
Implements profile signing and verification [Experimental]
This commit is contained in:
commit
a890f1be5e
4 changed files with 340 additions and 0 deletions
7
lib/bundles/inspec-artifact.rb
Normal file
7
lib/bundles/inspec-artifact.rb
Normal file
|
@ -0,0 +1,7 @@
|
|||
# encoding: utf-8
|
||||
# author: Dave Parfitt
|
||||
|
||||
libdir = File.dirname(__FILE__)
|
||||
$LOAD_PATH.unshift(libdir) unless $LOAD_PATH.include?(libdir)
|
||||
|
||||
require 'inspec-artifact/cli'
|
1
lib/bundles/inspec-artifact/README.md
Normal file
1
lib/bundles/inspec-artifact/README.md
Normal file
|
@ -0,0 +1 @@
|
|||
# TODO
|
284
lib/bundles/inspec-artifact/cli.rb
Normal file
284
lib/bundles/inspec-artifact/cli.rb
Normal file
|
@ -0,0 +1,284 @@
|
|||
# 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?
|
||||
fail "#{path_to_profile} doesn't appear to be a valid Inspec profile"
|
||||
end
|
||||
yaml = YAML.load_file(p.to_s)
|
||||
yaml = yaml.to_hash
|
||||
|
||||
if not yaml.key? 'name'
|
||||
fail 'Profile is invalid, name is not defined'
|
||||
end
|
||||
|
||||
if not yaml.key? 'version'
|
||||
fail 'Profile is invalid, version is not defined'
|
||||
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
|
||||
fail "Can't find #{public_keyfile}"
|
||||
end
|
||||
|
||||
if not VALID_PROFILE_DIGESTS.member? file_alg
|
||||
fail 'Invalid artifact digest algorithm detected'
|
||||
end
|
||||
|
||||
if not VALID_PROFILE_VERSIONS.member? file_version
|
||||
fail 'Invalid artifact version detected'
|
||||
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
|
48
test/functional/inspec_artifact_test.rb
Normal file
48
test/functional/inspec_artifact_test.rb
Normal file
|
@ -0,0 +1,48 @@
|
|||
# encoding: utf-8
|
||||
|
||||
require 'fileutils'
|
||||
require 'functional/helper'
|
||||
require 'securerandom'
|
||||
|
||||
describe 'inspec exec' do
|
||||
include FunctionalHelper
|
||||
|
||||
it 'can generate keys' do
|
||||
unique_key_name = SecureRandom.uuid()
|
||||
out = inspec("artifact generate --keyname #{unique_key_name}")
|
||||
# haha, ruby so shitty, there's ALWAYS gem problems
|
||||
#out.stderr.must_equal ''
|
||||
out.exit_status.must_equal 0
|
||||
stdout = out.stdout.force_encoding(Encoding::UTF_8)
|
||||
stdout.must_include 'Generating private key'
|
||||
stdout.must_include 'Generating public key'
|
||||
|
||||
FileUtils.rm("#{unique_key_name}.pem.pub")
|
||||
FileUtils.rm("#{unique_key_name}.pem.key")
|
||||
end
|
||||
|
||||
it 'can sign, verify and install a signed profile' do
|
||||
FileUtils.rm_f('profile-1.0.0.iaf')
|
||||
unique_key_name = SecureRandom.uuid()
|
||||
install_dir = SecureRandom.uuid()
|
||||
FileUtils.mkdir(install_dir)
|
||||
|
||||
out = inspec("artifact generate --keyname #{unique_key_name}")
|
||||
out.exit_status.must_equal 0
|
||||
|
||||
out = inspec("artifact sign-profile --profile #{example_profile} --keyname #{unique_key_name}")
|
||||
out.exit_status.must_equal 0
|
||||
|
||||
out = inspec("artifact install-profile --infile profile-1.0.0.iaf --destdir #{install_dir}")
|
||||
out.exit_status.must_equal 0
|
||||
stdout = out.stdout.force_encoding(Encoding::UTF_8)
|
||||
stdout.must_include "Installing to #{install_dir}"
|
||||
entries = Dir.entries install_dir
|
||||
entries.join.must_include "inspec.yml"
|
||||
FileUtils.rm_rf(install_dir)
|
||||
FileUtils.rm("#{unique_key_name}.pem.pub")
|
||||
FileUtils.rm("#{unique_key_name}.pem.key")
|
||||
FileUtils.rm('profile-1.0.0.iaf')
|
||||
end
|
||||
|
||||
end
|
Loading…
Reference in a new issue